Merge branch 'schema-sanity'
authorEevee <git@veekun.com>
Tue, 29 Mar 2011 02:14:24 +0000 (19:14 -0700)
committerEevee <git@veekun.com>
Tue, 29 Mar 2011 02:14:24 +0000 (19:14 -0700)
59 files changed:
pokedex/data/csv/ability_changelog_prose.csv
pokedex/data/csv/ability_names.csv [moved from pokedex/data/csv/ability_texts.csv with 99% similarity]
pokedex/data/csv/ability_prose.csv
pokedex/data/csv/berry_firmness_names.csv [moved from pokedex/data/csv/berry_firmness_texts.csv with 59% similarity]
pokedex/data/csv/contest_effect_prose.csv
pokedex/data/csv/contest_type_names.csv [moved from pokedex/data/csv/contest_type_texts.csv with 66% similarity]
pokedex/data/csv/egg_group_prose.csv
pokedex/data/csv/encounter_condition_prose.csv
pokedex/data/csv/encounter_condition_value_prose.csv
pokedex/data/csv/encounter_terrain_prose.csv
pokedex/data/csv/evolution_trigger_prose.csv
pokedex/data/csv/generation_names.csv [moved from pokedex/data/csv/generation_texts.csv with 70% similarity]
pokedex/data/csv/growth_rate_prose.csv
pokedex/data/csv/item_category_prose.csv
pokedex/data/csv/item_flag_prose.csv
pokedex/data/csv/item_fling_effect_prose.csv
pokedex/data/csv/item_game_indices.csv [moved from pokedex/data/csv/item_internal_ids.csv with 99% similarity]
pokedex/data/csv/item_names.csv [moved from pokedex/data/csv/item_texts.csv with 99% similarity]
pokedex/data/csv/item_pocket_names.csv [moved from pokedex/data/csv/item_pocket_texts.csv with 73% similarity]
pokedex/data/csv/item_prose.csv
pokedex/data/csv/language_names.csv [moved from pokedex/data/csv/language_texts.csv with 76% similarity]
pokedex/data/csv/location_area_prose.csv
pokedex/data/csv/location_areas.csv
pokedex/data/csv/location_game_indices.csv [moved from pokedex/data/csv/location_internal_ids.csv with 98% similarity]
pokedex/data/csv/location_names.csv [moved from pokedex/data/csv/location_texts.csv with 99% similarity]
pokedex/data/csv/move_battle_style_prose.csv
pokedex/data/csv/move_damage_class_prose.csv
pokedex/data/csv/move_effect_category_prose.csv
pokedex/data/csv/move_effect_changelog_prose.csv
pokedex/data/csv/move_effect_prose.csv
pokedex/data/csv/move_flag_type_prose.csv
pokedex/data/csv/move_meta_ailment_names.csv [moved from pokedex/data/csv/move_meta_ailment_texts.csv with 85% similarity]
pokedex/data/csv/move_meta_category_prose.csv
pokedex/data/csv/move_names.csv [moved from pokedex/data/csv/move_texts.csv with 99% similarity]
pokedex/data/csv/move_target_prose.csv
pokedex/data/csv/nature_names.csv [moved from pokedex/data/csv/nature_texts.csv with 98% similarity]
pokedex/data/csv/pokeathlon_stat_names.csv [moved from pokedex/data/csv/pokeathlon_stat_texts.csv with 54% similarity]
pokedex/data/csv/pokedex_prose.csv
pokedex/data/csv/pokemon_color_names.csv [moved from pokedex/data/csv/pokemon_color_texts.csv with 71% similarity]
pokedex/data/csv/pokemon_form_group_prose.csv
pokedex/data/csv/pokemon_form_names.csv [moved from pokedex/data/csv/pokemon_form_texts.csv with 99% similarity]
pokedex/data/csv/pokemon_game_indices.csv [moved from pokedex/data/csv/pokemon_internal_ids.csv with 99% similarity]
pokedex/data/csv/pokemon_habitat_names.csv [moved from pokedex/data/csv/pokemon_habitat_texts.csv with 72% similarity]
pokedex/data/csv/pokemon_move_method_prose.csv
pokedex/data/csv/pokemon_names.csv [moved from pokedex/data/csv/pokemon_texts.csv with 99% similarity]
pokedex/data/csv/pokemon_shape_prose.csv
pokedex/data/csv/region_names.csv [moved from pokedex/data/csv/region_texts.csv with 60% similarity]
pokedex/data/csv/stat_hint_names.csv [moved from pokedex/data/csv/stat_hint_texts.csv with 94% similarity]
pokedex/data/csv/stat_names.csv [moved from pokedex/data/csv/stat_texts.csv with 77% similarity]
pokedex/data/csv/super_contest_effect_prose.csv
pokedex/data/csv/type_names.csv [moved from pokedex/data/csv/type_texts.csv with 97% similarity]
pokedex/data/csv/version_names.csv [moved from pokedex/data/csv/version_texts.csv with 86% similarity]
pokedex/db/__init__.py
pokedex/db/markdown.py
pokedex/db/multilang.py [new file with mode: 0644]
pokedex/db/tables.py
pokedex/lookup.py
pokedex/struct/__init__.py
pokedex/tests/test_schema.py

index 45e8544..ee498c5 100644 (file)
@@ -1,4 +1,4 @@
-ability_changelog_id,language_id,effect
+ability_changelog_id,local_language_id,effect
 1,9,Has no effect in battle.
 2,9,Does not prevent regular KOs from full [HP]{mechanic}.
 3,9,Has no overworld effect.
similarity index 99%
rename from pokedex/data/csv/ability_texts.csv
rename to pokedex/data/csv/ability_names.csv
index 7fa67a7..cc6b089 100644 (file)
@@ -1,4 +1,4 @@
-ability_id,language_id,name
+ability_id,local_language_id,name
 1,1,あくしゅう
 1,5,Puanteur
 1,6,Duftnote
index ed87876..6850969 100644 (file)
@@ -1,4 +1,4 @@
-ability_id,language_id,effect,short_effect
+ability_id,local_language_id,effect,short_effect
 1,9,"This Pokémon's moves have approximately a 10% chance to make the target [flinch]{mechanic}.
 
 This ability does not stack with a held [King's Rock]{item}.
similarity index 59%
rename from pokedex/data/csv/berry_firmness_texts.csv
rename to pokedex/data/csv/berry_firmness_names.csv
index ff2ae24..c66ff7c 100644 (file)
@@ -1,4 +1,4 @@
-berry_firmness_id,language_id,name
+berry_firmness_id,local_language_id,name
 1,9,Very Soft
 2,9,Soft
 3,9,Hard
index b8061b8..2dd6114 100644 (file)
@@ -1,4 +1,4 @@
-contest_effect_id,language_id,flavor_text,effect
+contest_effect_id,local_language_id,flavor_text,effect
 1,9,A highly appealing move.,Gives a high number of appeal points wth no other effects.
 2,9,Affected by how well the appeal in front goes.,"If the Pokémon that appealed before the user earned less than three appeal points, user earns six; if three, user earns three; if more than three, user earns none."
 3,9,"After this move, the user is more easily startled.","If the user is jammed this turn after using this move, it will receive twice as many jam points."
similarity index 66%
rename from pokedex/data/csv/contest_type_texts.csv
rename to pokedex/data/csv/contest_type_names.csv
index 8b71fd1..9bebf39 100644 (file)
@@ -1,4 +1,4 @@
-contest_type_id,language_id,name,flavor,color
+contest_type_id,local_language_id,name,flavor,color
 1,9,Cool,Spicy,Red
 2,9,Beauty,Dry,Blue
 3,9,Cute,Sweet,Pink
index 43342f6..ffe8375 100644 (file)
@@ -1,4 +1,4 @@
-egg_group_id,language_id,name
+egg_group_id,local_language_id,name
 1,9,Monster
 2,9,Water 1
 3,9,Bug
index 87e524a..c6ee690 100644 (file)
@@ -1,4 +1,4 @@
-encounter_condition_id,language_id,name
+encounter_condition_id,local_language_id,name
 1,9,Swarm
 2,9,Time of day
 3,9,PokeRadar
index 8096c03..146b2c5 100644 (file)
@@ -1,4 +1,4 @@
-encounter_condition_value_id,language_id,name
+encounter_condition_value_id,local_language_id,name
 1,9,During a swarm
 2,9,Not during a swarm
 3,9,In the morning
index e30ed27..789ae19 100644 (file)
@@ -1,4 +1,4 @@
-encounter_terrain_id,language_id,name
+encounter_terrain_id,local_language_id,name
 1,9,Walking in tall grass or a cave
 2,9,Fishing with an Old Rod
 3,9,Fishing with a Good Rod
index 33d3983..625ae13 100644 (file)
@@ -1,4 +1,4 @@
-evolution_trigger_id,language_id,name
+evolution_trigger_id,local_language_id,name
 1,9,Level_up
 2,9,Trade
 3,9,Use_item
similarity index 70%
rename from pokedex/data/csv/generation_texts.csv
rename to pokedex/data/csv/generation_names.csv
index 81bfd2c..50d3b55 100644 (file)
@@ -1,4 +1,4 @@
-generation_id,language_id,name
+generation_id,local_language_id,name
 1,9,Generation I
 2,9,Generation II
 3,9,Generation III
index af6af90..f2f79b3 100644 (file)
@@ -1,4 +1,4 @@
-growth_rate_id,language_id,name
+growth_rate_id,local_language_id,name
 1,9,slow
 2,9,medium
 3,9,fast
index 0e8f1d2..7d09263 100644 (file)
@@ -1,4 +1,4 @@
-item_category_id,language_id,name
+item_category_id,local_language_id,name
 1,9,Stat boosts
 2,9,Effort drop
 3,9,Medicine
index 4d48bd2..037b563 100644 (file)
@@ -1,4 +1,4 @@
-item_flag_id,language_id,name,description
+item_flag_id,local_language_id,name,description
 1,9,Countable,Has a count in the bag
 2,9,Consumable,Consumed when used
 3,9,Usable_overworld,Usable outside battle
index e965e03..038d393 100644 (file)
@@ -1,4 +1,4 @@
-item_fling_effect_id,language_id,effect
+item_fling_effect_id,local_language_id,effect
 1,9,Badly poisons the target.
 2,9,Burns the target.
 3,9,Immediately activates the berry's effect on the target.
similarity index 99%
rename from pokedex/data/csv/item_internal_ids.csv
rename to pokedex/data/csv/item_game_indices.csv
index 69349c5..153763f 100644 (file)
@@ -1,4 +1,4 @@
-item_id,generation_id,internal_id
+item_id,generation_id,game_index
 1,3,1
 1,4,1
 1,5,1
similarity index 99%
rename from pokedex/data/csv/item_texts.csv
rename to pokedex/data/csv/item_names.csv
index 7eaa8a8..223a522 100644 (file)
@@ -1,4 +1,4 @@
-item_id,language_id,name
+item_id,local_language_id,name
 1,1,マスターボール
 1,5,Master Ball
 1,6,Meisterball
similarity index 73%
rename from pokedex/data/csv/item_pocket_texts.csv
rename to pokedex/data/csv/item_pocket_names.csv
index 0be9437..ca85fd6 100644 (file)
@@ -1,4 +1,4 @@
-item_pocket_id,language_id,name
+item_pocket_id,local_language_id,name
 1,9,Items
 2,9,Medicine
 3,9,Poké Balls
index 7d3b85b..38ce9e7 100644 (file)
@@ -1,4 +1,4 @@
-item_id,language_id,short_effect,effect
+item_id,local_language_id,short_effect,effect
 1,9,,In battle: Captures one Pokémon without fail.  Has a capture rate of 255.
 2,9,,In battle: Attempts to capture one Pokémon.  Has a capture rate of 2.
 3,9,,In battle: Attempts to capture one Pokémon.  Has a capture rate of 1.5.
similarity index 76%
rename from pokedex/data/csv/language_texts.csv
rename to pokedex/data/csv/language_names.csv
index 8e1cdfa..42663ae 100644 (file)
@@ -1,4 +1,4 @@
-lang_id,language_id,name
+language_id,local_language_id,name
 1,9,Japanese
 2,9,Official Roomaji
 3,9,Korean
index 43ac3b3..5aec6b5 100644 (file)
@@ -1,4 +1,4 @@
-location_area_id,language_id,name
+location_area_id,local_language_id,name
 1,9,
 2,9,
 3,9,
index c4abce7..648446b 100644 (file)
@@ -1,4 +1,4 @@
-id,location_id,internal_id,identifier
+id,location_id,game_index,identifier
 1,1,1,
 2,2,2,
 3,3,3,
similarity index 98%
rename from pokedex/data/csv/location_internal_ids.csv
rename to pokedex/data/csv/location_game_indices.csv
index 7e11ecf..798db39 100644 (file)
@@ -1,4 +1,4 @@
-location_id,generation_id,internal_id
+location_id,generation_id,game_index
 1,4,7
 2,4,9
 3,4,11
similarity index 99%
rename from pokedex/data/csv/location_texts.csv
rename to pokedex/data/csv/location_names.csv
index a36cfe2..ae74b6a 100644 (file)
@@ -1,4 +1,4 @@
-location_id,language_id,name
+location_id,local_language_id,name
 1,9,Canalave City
 2,9,Eterna City
 3,9,Pastoria City
index 408f76c..441b73b 100644 (file)
@@ -1,4 +1,4 @@
-move_battle_style_id,language_id,name
+move_battle_style_id,local_language_id,name
 1,9,Attack
 2,9,Defense
 3,9,Support
index a7cd3d7..2083337 100644 (file)
@@ -1,4 +1,4 @@
-move_damage_class_id,language_id,name,description
+move_damage_class_id,local_language_id,name,description
 1,9,non-damaging,No damage
 2,9,physical,"Physical damage, controlled by Attack and Defense"
 3,9,special,"Special damage, controlled by Special Attack and Special Defense"
index 9d8e1a0..edf7734 100644 (file)
@@ -1,4 +1,4 @@
-move_effect_category_id,language_id,name
+move_effect_category_id,local_language_id,name
 1,9,Regular damage
 2,9,Power varies
 3,9,Special damage
index 0305775..11df252 100644 (file)
@@ -1,4 +1,4 @@
-move_effect_changelog_id,language_id,effect
+move_effect_changelog_id,local_language_id,effect
 1,9,"Halves the target's [Defense]{mechanic} for damage calculation, which is similar to doubling the attack's [power]{mechanic}."
 2,9,Hits Pokémon under the effects of [Dig]{move} and [Fly]{move}.
 3,9,Does nothing in trainer battles.
index 6103c50..68610f9 100644 (file)
@@ -1,4 +1,4 @@
-move_effect_id,language_id,short_effect,effect
+move_effect_id,local_language_id,short_effect,effect
 1,9,Inflicts regular damage with no additional effect.,Inflicts [regular damage]{mechanic}.
 2,9,Puts the target to sleep.,Puts the target to [sleep]{mechanic}.
 3,9,Has a $effect_chance% chance to poison the target.,Inflicts [regular damage]{mechanic}.  Has a $effect_chance% chance to [poison]{mechanic} the target.
index 3083f83..33e7fe9 100644 (file)
@@ -1,4 +1,4 @@
-move_flag_type_id,language_id,name,description
+move_flag_type_id,local_language_id,name,description
 1,9,Makes contact,"User touches the target.  This triggers some abilities (e.g., [Static]{ability}) and items (e.g., [Sticky Barb]{item})."
 2,9,Has a charging turn,This move has a charging turn that can be skipped with a [Power Herb]{item}.
 3,9,Must recharge,"The turn after this move is used, the Pokémon's action is skipped so it can recharge."
similarity index 85%
rename from pokedex/data/csv/move_meta_ailment_texts.csv
rename to pokedex/data/csv/move_meta_ailment_names.csv
index 0512c3e..a202a71 100644 (file)
@@ -1,4 +1,4 @@
-move_meta_ailment_id,language_id,name
+move_meta_ailment_id,local_language_id,name
 -1,9,????
 0,9,none
 1,9,Paralysis
index 287d5fa..8ea22f1 100644 (file)
@@ -1,4 +1,4 @@
-move_meta_category_id,language_id,description
+move_meta_category_id,local_language_id,description
 0,9,Inflicts damage
 1,9,No damage; inflicts status ailment
 2,9,No damage; lowers target's stats or raises user's stats
similarity index 99%
rename from pokedex/data/csv/move_texts.csv
rename to pokedex/data/csv/move_names.csv
index 321a0ee..cacf76f 100644 (file)
@@ -1,4 +1,4 @@
-move_id,language_id,name
+move_id,local_language_id,name
 1,1,はたく
 1,5,Écras'Face
 1,6,Pfund
index d907c5c..b1c2aee 100644 (file)
@@ -1,4 +1,4 @@
-move_target_id,language_id,name,description
+move_target_id,local_language_id,name,description
 1,9,Specific move,One specific move.  How this move is chosen depends upon on the move being used.
 2,9,Selected Pokémon,"One other Pokémon on the field, selected by the trainer.  Stolen moves reuse the same target."
 3,9,Ally,The user's ally (if any).
similarity index 98%
rename from pokedex/data/csv/nature_texts.csv
rename to pokedex/data/csv/nature_names.csv
index e5b4e03..eaab7a5 100644 (file)
@@ -1,4 +1,4 @@
-nature_id,language_id,name
+nature_id,local_language_id,name
 1,1,がんばりや
 1,5,Hardi
 1,6,Robust
similarity index 54%
rename from pokedex/data/csv/pokeathlon_stat_texts.csv
rename to pokedex/data/csv/pokeathlon_stat_names.csv
index 1112bba..4f47d7e 100644 (file)
@@ -1,4 +1,4 @@
-pokeathlon_stat_id,language_id,name
+pokeathlon_stat_id,local_language_id,name
 1,9,Speed
 2,9,Power
 3,9,Skill
index 32e93b0..b739880 100644 (file)
@@ -1,4 +1,4 @@
-pokedex_id,language_id,name,description
+pokedex_id,local_language_id,name,description
 1,9,National,Entire National dex
 2,9,Kanto,Red/Blue/Yellow Kanto dex
 3,9,Original Johto,"Gold/Silver/Crystal Johto dex—called the ""New"" Pokédex in-game"
similarity index 71%
rename from pokedex/data/csv/pokemon_color_texts.csv
rename to pokedex/data/csv/pokemon_color_names.csv
index d25c717..6e8eb80 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_color_id,language_id,name
+pokemon_color_id,local_language_id,name
 1,9,Black
 2,9,Blue
 3,9,Brown
index 03dca2e..ec15308 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_form_group_id,language_id,term,description
+pokemon_form_group_id,local_language_id,term,description
 172,9,,"Spiky-eared Pichu can only be received by taking the shiny Pichu from an official promotion to [Celebi]{pokemon}'s shrine in [Ilex Forest]{location}.  Spiky-eared Pichu is always female, cannot evolve, and cannot be taken into the Wi-Fi Club or the Union Room, but is otherwise a normal Pichu."
 201,9,,Forms only affect appearance.  A form is determined at random before a wild encounter and cannot be changed.
 351,9,Form,"Form changes along with type to match the [weather]{mechanic} in battle, due to [Forecast]{ability}.  Castform is always in its normal form outside of battle, regardless of weather."
similarity index 99%
rename from pokedex/data/csv/pokemon_form_texts.csv
rename to pokedex/data/csv/pokemon_form_names.csv
index 70d4dfe..e51e486 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_form_id,language_id,name
+pokemon_form_id,local_language_id,name
 1,9,
 2,9,
 3,9,
similarity index 99%
rename from pokedex/data/csv/pokemon_internal_ids.csv
rename to pokedex/data/csv/pokemon_game_indices.csv
index a5664e6..3c4032a 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_id,generation_id,internal_id
+pokemon_id,generation_id,game_index
 1,1,153
 1,2,1
 1,3,1
similarity index 72%
rename from pokedex/data/csv/pokemon_habitat_texts.csv
rename to pokedex/data/csv/pokemon_habitat_names.csv
index d78b059..d974bc6 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_habitat_id,language_id,name
+pokemon_habitat_id,local_language_id,name
 1,9,cave
 2,9,forest
 3,9,grassland
index b39a211..8d3802c 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_move_method_id,language_id,name,description
+pokemon_move_method_id,local_language_id,name,description
 1,9,Level up,Learned when a Pokémon reaches a certain level.
 2,9,Egg,"Appears on a newly-hatched Pokémon, if the father had the same move."
 3,9,Tutor,Can be taught at any time by an NPC.
similarity index 99%
rename from pokedex/data/csv/pokemon_texts.csv
rename to pokedex/data/csv/pokemon_names.csv
index 87f77b1..09db3d5 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_id,language_id,name,species
+pokemon_id,local_language_id,name,species
 1,1,フシギダネ,
 1,2,Fushigidane,
 1,3,이상해씨,
index 5a3cf37..38fca57 100644 (file)
@@ -1,4 +1,4 @@
-pokemon_shape_id,language_id,name,awesome_name
+pokemon_shape_id,local_language_id,name,awesome_name
 1,9,Ball,Pomaceous
 2,9,Squiggle,Caudal
 3,9,Fish,Ichthyic
similarity index 60%
rename from pokedex/data/csv/region_texts.csv
rename to pokedex/data/csv/region_names.csv
index 5f05e2b..43a76bc 100644 (file)
@@ -1,4 +1,4 @@
-region_id,language_id,name
+region_id,local_language_id,name
 1,9,Kanto
 2,9,Johto
 3,9,Hoenn
similarity index 94%
rename from pokedex/data/csv/stat_hint_texts.csv
rename to pokedex/data/csv/stat_hint_names.csv
index 36acada..44b3450 100644 (file)
@@ -1,4 +1,4 @@
-stat_hint_id,language_id,message
+stat_hint_id,local_language_id,message
 1,9,Loves to eat
 2,9,Proud of its power
 3,9,Sturdy body
similarity index 77%
rename from pokedex/data/csv/stat_texts.csv
rename to pokedex/data/csv/stat_names.csv
index ceb73a2..aca8e1a 100644 (file)
@@ -1,4 +1,4 @@
-stat_id,language_id,name
+stat_id,local_language_id,name
 1,9,HP
 2,9,Attack
 3,9,Defense
index 8585c26..3715323 100644 (file)
@@ -1,4 +1,4 @@
-super_contest_effect_id,language_id,flavor_text
+super_contest_effect_id,local_language_id,flavor_text
 1,9,Enables the user to perform first in the next turn.
 2,9,Enables the user to perform last in the next turn.
 4,9,Earn +2 if the Judge's Voltage goes up.
similarity index 97%
rename from pokedex/data/csv/type_texts.csv
rename to pokedex/data/csv/type_names.csv
index 4ad1b6e..49c3165 100644 (file)
@@ -1,4 +1,4 @@
-type_id,language_id,name
+type_id,local_language_id,name
 1,1,ノーマル
 1,5,Normal
 1,6,Normal
similarity index 86%
rename from pokedex/data/csv/version_texts.csv
rename to pokedex/data/csv/version_names.csv
index 518a11c..1902bfe 100644 (file)
@@ -1,4 +1,4 @@
-version_id,language_id,name
+version_id,local_language_id,name
 1,9,Red
 2,9,Blue
 3,9,Yellow
index 13e9a19..a6c8f6e 100644 (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
index 0bc6971..dd86746 100644 (file)
@@ -56,34 +56,25 @@ class MarkdownString(object):
         """
         return self.source_text
 
+def _markdownify_effect_text(move, effect_text):
+    effect_text = effect_text.replace(
+        u'$effect_chance',
+        unicode(move.effect_chance),
+    )
 
-class _MoveEffects(object):
-    def __init__(self, effect_column, move):
-        self.effect_column = effect_column
-        self.move = move
-
-    def __contains__(self, lang):
-        return lang in self.move.move_effect.prose
-
-    def __getitem__(self, lang):
-        try:
-            effect_text = getattr(self.move.move_effect.prose[lang], self.effect_column)
-        except AttributeError:
-            return None
-        effect_text = effect_text.replace(
-            u'$effect_chance',
-            unicode(self.move.effect_chance),
-        )
+    return MarkdownString(effect_text)
 
-        return MarkdownString(effect_text)
-
-class MoveEffectsProperty(object):
+class MoveEffectProperty(object):
     """Property that wraps move effects.  Used like this:
 
-        MoveClass.effects = MoveEffectProperty('effect')
+        MoveClass.effect = MoveEffectProperty('effect')
+
+        some_move.effect            # returns a MarkdownString
+        some_move.effect.as_html    # returns a chunk of HTML
 
-        some_move.effects[lang]            # returns a MarkdownString
-        some_move.effects[lang].as_html    # returns a chunk of HTML
+    This class attempts to detect if the wrapped property is a dict-based
+    association proxy, and will act like such a dict if so.  Don't rely on it
+    for querying, of course.
 
     This class also performs simple substitution on the effect, replacing
     `$effect_chance` with the move's actual effect chance.
@@ -92,8 +83,17 @@ class MoveEffectsProperty(object):
     def __init__(self, effect_column):
         self.effect_column = effect_column
 
-    def __get__(self, move, move_class):
-        return _MoveEffects(self.effect_column, move)
+    def __get__(self, obj, cls):
+        prop = getattr(obj.move_effect, self.effect_column)
+        if isinstance(prop, dict):
+            # Looks like a dict proxy; markdownify everyone
+            newdict = dict(prop)
+            for key in newdict:
+                newdict[key] = _markdownify_effect_text(obj, newdict[key])
+            return newdict
+
+        # Otherwise, scalar prop.  Boring
+        return _markdownify_effect_text(obj, prop)
 
 class MarkdownColumn(sqlalchemy.types.TypeDecorator):
     """Generic SQLAlchemy column type for Markdown text.
diff --git a/pokedex/db/multilang.py b/pokedex/db/multilang.py
new file mode 100644 (file)
index 0000000..8ac1bfc
--- /dev/null
@@ -0,0 +1,164 @@
+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.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):
+    """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.
+
+    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.
+    - `(relation_name)_class`, 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
+    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'
+
+    Translations = type(_table_name, (object,), {
+        '_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),
+            primary_key=True, nullable=False),
+        Column('local_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={
+        'foreign_id': synonym(foreign_key_name),
+        'local_language': relationship(language_class,
+            primaryjoin=table.c.local_language_id == language_class.id,
+            lazy='joined',
+            innerjoin=True),
+    })
+
+    # Add full-table relations to the original class
+    # Foo.bars_table
+    setattr(foreign_class, relation_name + '_table', Translations)
+    # Foo.bars
+    setattr(foreign_class, relation_name, relationship(Translations,
+        primaryjoin=foreign_class.id == Translations.foreign_id,
+        collection_class=attribute_mapped_collection('local_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.  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.
+    local_relation_name = relation_name + '_local'
+    language_class_a = aliased(language_class)
+    setattr(foreign_class, local_relation_name, relationship(Translations,
+        primaryjoin=and_(
+            foreign_class.id == Translations.foreign_id,
+            Translations.local_language_id == select(
+                [language_class_a.id],
+                language_class_a.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.local_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."""
+    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)
index f8afa9b..1c1db60 100644 (file)
@@ -27,89 +27,78 @@ The singular-name property returns the name in the default language, English.
 # XXX: Check if "gametext" is set correctly everywhere
 
 import collections
+from functools import partial
 
 from sqlalchemy import Column, ForeignKey, MetaData, PrimaryKeyConstraint, Table, UniqueConstraint
-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, synonym, mapper,
-    )
-from sqlalchemy.orm.session import Session, object_session
-from sqlalchemy.orm.collections import attribute_mapped_collection
+from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.orm import backref, relation
+from sqlalchemy.orm.session import Session
+from sqlalchemy.orm.interfaces import AttributeExtension
 from sqlalchemy.sql import and_
-from sqlalchemy.sql.expression import ColumnOperators
 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 = []
-
-class TableMetaclass(DeclarativeMeta):
-    def __init__(cls, name, bases, attrs):
-        super(TableMetaclass, cls).__init__(name, bases, attrs)
-        if hasattr(cls, '__tablename__'):
-            table_classes.append(cls)
+class TableSuperclass(object):
+    """Superclass for declarative tables, to give them some generic niceties
+    like stringification.
+    """
+    def __unicode__(self):
+        """Be as useful as possible.  Show the primary key, and an identifier
+        if we've got one.
+        """
+        typename = u'.'.join((__name__, type(self).__name__))
 
-metadata = MetaData()
-TableBase = declarative_base(metadata=metadata, metaclass=TableMetaclass)
+        pk_constraint = self.__table__.primary_key
+        if not pk_constraint:
+            return u"<%s object at %x>" % (typename, id(self))
 
-### Helper classes
-class Named(object):
-    """Mixin for objects that have names"""
-    def __unicode__(self):
+        pk = u', '.join(unicode(getattr(self, column.name))
+            for column in pk_constraint.columns)
         try:
-            return '<%s: %s>' % (type(self).__name__, self.identifier)
+            return u"<%s object (%s): %s>" % (typename, pk, self.identifier)
         except AttributeError:
-            return '<%s>' % type(self).__name__
+            return u"<%s object (%s)>" % (typename, pk)
 
     def __str__(self):
-        return unicode(self).encode('utf-8')
-
-    def __repr__(self):
-        return str(self)
-
-class OfficiallyNamed(Named):
-    """Mixin for stuff with official names"""
-
-class UnofficiallyNamed(Named):
-    """Mixin for stuff with unofficial names"""
+        return unicode(self).encode('utf8')
 
-class LanguageSpecific(object):
-    """Mixin for prose and text tables"""
-    @declared_attr
-    def language_id(cls):
-        return Column(Integer, ForeignKey('languages.id'), primary_key=True, nullable=False,
-            info=dict(description="The language"))
+metadata = MetaData()
+TableBase = declarative_base(metadata=metadata, cls=TableSuperclass)
 
-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)
+### Need Language first, to create the partial() below
 
-class ProseColumn(LanguageSpecificColumn):
-    """A column that will appear in the corresponding _prose table"""
+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."))
 
-class TextColumn(LanguageSpecificColumn):
-    """A column that will appear in the corresponding _text table"""
+create_translation_table = partial(multilang.create_translation_table, language_class=Language)
 
+create_translation_table('language_names', Language, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
 
 ### The actual tables
 
-class Ability(TableBase, OfficiallyNamed):
+class Ability(TableBase):
     u"""An ability a Pokémon can have, such as Static or Pressure.
     """
     __tablename__ = 'abilities'
@@ -120,10 +109,17 @@ class Ability(TableBase, OfficiallyNamed):
         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'))
+
+create_translation_table('ability_names', 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."""
@@ -135,10 +131,13 @@ class AbilityChangelog(TableBase):
         info=dict(description="The ID of the ability that changed"))
     changed_in_version_group_id = Column(Integer, ForeignKey('version_groups.id'), nullable=False,
         info=dict(description="The ID of the version group in which the ability changed"))
-    effect = ProseColumn(markdown.MarkdownColumn(255), plural='effects', nullable=False,
+
+create_translation_table('ability_changelog_prose', AbilityChangelog, 'prose',
+    effect = Column(markdown.MarkdownColumn(255), nullable=False,
         info=dict(description="A description of the old behavior", format='markdown'))
+)
 
-class AbilityFlavorText(TableBase, LanguageSpecific):
+class AbilityFlavorText(TableBase):
     u"""In-game flavor text of an ability
     """
     __tablename__ = 'ability_flavor_text'
@@ -146,6 +145,8 @@ class AbilityFlavorText(TableBase, LanguageSpecific):
         info=dict(description="The ID of the ability"))
     version_group_id = Column(Integer, ForeignKey('version_groups.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description="The ID of the version group this flavor text is taken from"))
+    language_id = Column(Integer, ForeignKey('languages.id'), primary_key=True, nullable=False,
+        info=dict(description="The language"))
     flavor_text = Column(Unicode(64), nullable=False,
         info=dict(description="The actual flavor text", official=True, format='gametext'))
 
@@ -176,7 +177,7 @@ class Berry(TableBase):
     smoothness = Column(Integer, nullable=False,
         info=dict(description="The smoothness of this Berry, used in making Pokéblocks or Poffins"))
 
-class BerryFirmness(TableBase, OfficiallyNamed):
+class BerryFirmness(TableBase):
     u"""A Berry firmness, such as "hard" or "very soft".
     """
     __tablename__ = 'berry_firmness'
@@ -186,6 +187,11 @@ class BerryFirmness(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(10), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
+create_translation_table('berry_firmness_names', BerryFirmness, 'names',
+    name = Column(Unicode(10), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class BerryFlavor(TableBase):
     u"""A Berry flavor level.
     """
@@ -217,12 +223,15 @@ class ContestEffect(TableBase):
         info=dict(description="The base number of hearts the user of this move gets"))
     jam = Column(SmallInteger, nullable=False,
         info=dict(description="The base number of hearts the user's opponent loses"))
-    flavor_text = ProseColumn(Unicode(64), plural='flavor_texts', nullable=False,
-        info=dict(description="The in-game description of this effect", official=True, format='gametext'))
-    effect = ProseColumn(Unicode(255), plural='effects', nullable=False,
-        info=dict(description="A detailed description of the effect", format='plaintext'))
 
-class ContestType(TableBase, OfficiallyNamed):
+create_translation_table('contest_effect_prose', ContestEffect, 'prose',
+    flavor_text = Column(Unicode(64), nullable=False,
+        info=dict(description="The in-game description of this effect", official=True, format='gametext')),
+    effect = Column(Unicode(255), nullable=False,
+        info=dict(description="A detailed description of the effect", format='plaintext')),
+)
+
+class ContestType(TableBase):
     u"""A Contest type, such as "cool" or "smart", and their associated Berry flavors and Pokéblock colors.
     """
     __tablename__ = 'contest_types'
@@ -231,12 +240,17 @@ class ContestType(TableBase, OfficiallyNamed):
         info=dict(description="A unique ID for this Contest type"))
     identifier = Column(Unicode(6), nullable=False,
         info=dict(description="An identifier", format='identifier'))
-    flavor = TextColumn(Unicode(6), nullable=False, plural='flavors',
-        info=dict(description="The name of the corresponding Berry flavor", official=True, format='plaintext'))
-    color = TextColumn(Unicode(6), nullable=False, plural='colors',
-        info=dict(description=u"The name of the corresponding Pokéblock color", official=True, format='plaintext'))
 
-class EggGroup(TableBase, UnofficiallyNamed):
+create_translation_table('contest_type_names', ContestType, 'names',
+    name = Column(Unicode(6), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+    flavor = Column(Unicode(6), nullable=False,
+        info=dict(description="The name of the corresponding Berry flavor", official=True, format='plaintext')),
+    color = Column(Unicode(6), nullable=False,
+        info=dict(description=u"The name of the corresponding Pokéblock color", official=True, format='plaintext')),
+)
+
+class EggGroup(TableBase):
     u"""An Egg group. Usually, two Pokémon can breed if they share an Egg Group.
 
     (exceptions are the Ditto and No Eggs groups)
@@ -248,6 +262,11 @@ class EggGroup(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u"An identifier.", format='identifier'))
 
+create_translation_table('egg_group_prose', EggGroup, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class Encounter(TableBase):
     u"""Encounters with wild Pokémon.
 
@@ -288,7 +307,7 @@ class Encounter(TableBase):
     max_level = Column(Integer, nullable=False, autoincrement=False,
         info=dict(description=u"The maxmum level of the encountered Pokémon"))
 
-class EncounterCondition(TableBase, UnofficiallyNamed):
+class EncounterCondition(TableBase):
     u"""A conditions in the game world that affects Pokémon encounters, such as time of day.
     """
 
@@ -299,7 +318,12 @@ class EncounterCondition(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(64), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
-class EncounterConditionValue(TableBase, UnofficiallyNamed):
+create_translation_table('encounter_condition_prose', EncounterCondition, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
+class EncounterConditionValue(TableBase):
     u"""A possible state for a condition; for example, the state of 'swarm' could be 'swarm' or 'no swarm'.
     """
 
@@ -314,6 +338,11 @@ class EncounterConditionValue(TableBase, UnofficiallyNamed):
     is_default = Column(Boolean, nullable=False,
         info=dict(description='Set if this value is the default state for the condition'))
 
+create_translation_table('encounter_condition_value_prose', EncounterConditionValue, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
 class EncounterConditionValueMap(TableBase):
     u"""Maps encounters to the specific conditions under which they occur.
     """
@@ -323,7 +352,7 @@ class EncounterConditionValueMap(TableBase):
     encounter_condition_value_id = Column(Integer, ForeignKey('encounter_condition_values.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description="The ID of the encounter condition value"))
 
-class EncounterTerrain(TableBase, UnofficiallyNamed):
+class EncounterTerrain(TableBase):
     u"""A way the player can enter a wild encounter, e.g., surfing, fishing, or walking through tall grass.
     """
 
@@ -334,6 +363,11 @@ class EncounterTerrain(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(64), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
+create_translation_table('encounter_terrain_prose', EncounterTerrain, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
 class EncounterSlot(TableBase):
     u"""An abstract "slot" within a terrain, associated with both some set of conditions and a rarity.
 
@@ -364,7 +398,7 @@ class EvolutionChain(TableBase):
     baby_trigger_item_id = Column(Integer, ForeignKey('items.id'), nullable=True,
         info=dict(description="Item that a parent must hold while breeding to produce a baby"))
 
-class EvolutionTrigger(TableBase, UnofficiallyNamed):
+class EvolutionTrigger(TableBase):
     u"""An evolution type, such as "level" or "trade".
     """
     __tablename__ = 'evolution_triggers'
@@ -374,6 +408,11 @@ class EvolutionTrigger(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
+create_translation_table('evolution_trigger_prose', EvolutionTrigger, 'prose',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
 class Experience(TableBase):
     u"""EXP needed for a certain level with a certain growth rate
     """
@@ -385,7 +424,7 @@ class Experience(TableBase):
     experience = Column(Integer, nullable=False,
         info=dict(description="The number of EXP points needed to get to that level"))
 
-class Generation(TableBase, OfficiallyNamed):
+class Generation(TableBase):
     u"""A Generation of the Pokémon franchise
     """
     __tablename__ = 'generations'
@@ -399,7 +438,12 @@ class Generation(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u'An identifier', format='identifier'))
 
-class GrowthRate(TableBase, UnofficiallyNamed):
+create_translation_table('generation_names', Generation, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
+class GrowthRate(TableBase):
     u"""Growth rate of a Pokémon, i.e. the EXP → level function.
     """
     __tablename__ = 'growth_rates'
@@ -411,7 +455,12 @@ class GrowthRate(TableBase, UnofficiallyNamed):
     formula = Column(Unicode(500), nullable=False,
         info=dict(description="The formula", format='latex'))
 
-class Item(TableBase, OfficiallyNamed):
+create_translation_table('growth_rate_prose', GrowthRate, 'prose',
+    name = Column(Unicode(20), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
+class Item(TableBase):
     u"""An Item from the games, like "Poké Ball" or "Bicycle".
     """
     __tablename__ = 'items'
@@ -428,10 +477,6 @@ class Item(TableBase, OfficiallyNamed):
         info=dict(description=u"Power of the move Fling when used with this item."))
     fling_effect_id = Column(Integer, ForeignKey('item_fling_effects.id'), nullable=True,
         info=dict(description=u"ID of the fling-effect of the move Fling when used with this item. Note that these are different from move effects."))
-    short_effect = ProseColumn(Unicode(256), plural='short_effects', nullable=False,
-        info=dict(description="A short summary of the effect", format='plaintext'))
-    effect = ProseColumn(markdown.MarkdownColumn(5120), plural='effects', nullable=False,
-        info=dict(description=u"Detailed description of the item's effect.", format='markdown'))
 
     @property
     def appears_underground(self):
@@ -439,7 +484,18 @@ class Item(TableBase, OfficiallyNamed):
         """
         return any(flag.identifier == u'underground' for flag in self.flags)
 
-class ItemCategory(TableBase, UnofficiallyNamed):
+create_translation_table('item_names', Item, 'names',
+    name = Column(Unicode(20), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+create_translation_table('item_prose', Item, 'prose',
+    short_effect = Column(Unicode(256), nullable=False,
+        info=dict(description="A short summary of the effect", format='plaintext')),
+    effect = Column(markdown.MarkdownColumn(5120), nullable=False,
+        info=dict(description=u"Detailed description of the item's effect.", format='markdown')),
+)
+
+class ItemCategory(TableBase):
     u"""An item category
     """
     # XXX: This is fanon, right?
@@ -452,7 +508,12 @@ class ItemCategory(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
-class ItemFlag(TableBase, UnofficiallyNamed):
+create_translation_table('item_category_prose', ItemCategory, 'prose',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
+class ItemFlag(TableBase):
     u"""An item attribute such as "consumable" or "holdable".
     """
     __tablename__ = 'item_flags'
@@ -461,8 +522,13 @@ class ItemFlag(TableBase, UnofficiallyNamed):
         info=dict(description="A numeric ID"))
     identifier = Column(Unicode(24), nullable=False,
         info=dict(description="Identifier of the flag", format='identifier'))
-    description = ProseColumn(Unicode(64), plural='descriptions', nullable=False,
-        info=dict(description="Short description of the flag", format='plaintext'))
+
+create_translation_table('item_flag_prose', ItemFlag, 'prose',
+    name = Column(Unicode(24), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(Unicode(64), nullable=False,
+        info=dict(description="Short description of the flag", format='plaintext')),
+)
 
 class ItemFlagMap(TableBase):
     u"""Maps an item flag to its item.
@@ -473,7 +539,7 @@ class ItemFlagMap(TableBase):
     item_flag_id = Column(Integer, ForeignKey('item_flags.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description="The ID of the item flag"))
 
-class ItemFlavorText(TableBase, LanguageSpecific):
+class ItemFlavorText(TableBase):
     u"""An in-game description of an item
     """
     __tablename__ = 'item_flavor_text'
@@ -482,6 +548,8 @@ class ItemFlavorText(TableBase, LanguageSpecific):
         info=dict(description="The ID of the item"))
     version_group_id = Column(Integer, ForeignKey('version_groups.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description="ID of the version group that sports this text"))
+    language_id = Column(Integer, ForeignKey('languages.id'), primary_key=True, nullable=False,
+        info=dict(description="The language"))
     flavor_text = Column(Unicode(255), nullable=False,
         info=dict(description="The flavor text itself", official=True, format='gametext'))
 
@@ -492,21 +560,24 @@ class ItemFlingEffect(TableBase):
     __singlename__ = 'item_fling_effect'
     id = Column(Integer, primary_key=True, nullable=False,
         info=dict(description="A numeric ID"))
-    effect = ProseColumn(Unicode(255), plural='effects', nullable=False,
-        info=dict(description="Description of the effect", format='plaintext'))
 
-class ItemInternalID(TableBase):
+create_translation_table('item_fling_effect_prose', ItemFlingEffect, 'prose',
+    effect = Column(Unicode(255), nullable=False,
+        info=dict(description="Description of the effect", format='plaintext')),
+)
+
+class ItemGameIndex(TableBase):
     u"""The internal ID number a game uses for an item
     """
-    __tablename__ = 'item_internal_ids'
+    __tablename__ = 'item_game_indices'
     item_id = Column(Integer, ForeignKey('items.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description="The database ID of the item"))
     generation_id = Column(Integer, ForeignKey('generations.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description="ID of the generation of games"))
-    internal_id = Column(Integer, nullable=False,
+    game_index = Column(Integer, nullable=False,
         info=dict(description="Internal ID of the item in the generation"))
 
-class ItemPocket(TableBase, OfficiallyNamed):
+class ItemPocket(TableBase):
     u"""A pocket that categorizes items
     """
     __tablename__ = 'item_pockets'
@@ -516,43 +587,12 @@ class ItemPocket(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description="An identifier of this pocket", format='identifier'))
 
-class Language(TableBase, OfficiallyNamed):
-    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."))
-
-    # Languages compare equal to its identifier, so a dictionary of
-    # translations, with a Language as the key, can be indexed by the identifier
-    def __eq__(self, other):
-        try:
-            return (
-                    self is other or
-                    self.identifier == other or
-                    self.identifier == other.identifier
-                )
-        except AttributeError:
-            return NotImplemented
-
-    def __ne__(self, other):
-        return not (self == other)
-
-    def __hash__(self):
-        return hash(self.identifier)
+create_translation_table('item_pocket_names', ItemPocket, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
 
-class Location(TableBase, OfficiallyNamed):
+class Location(TableBase):
     u"""A place in the Pokémon world
     """
     __tablename__ = 'locations'
@@ -564,7 +604,12 @@ class Location(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(64), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
-class LocationArea(TableBase, UnofficiallyNamed):
+create_translation_table('location_names', Location, 'names',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
+class LocationArea(TableBase):
     u"""A sub-area of a location
     """
     __tablename__ = 'location_areas'
@@ -573,11 +618,16 @@ class LocationArea(TableBase, UnofficiallyNamed):
         info=dict(description="A numeric ID"))
     location_id = Column(Integer, ForeignKey('locations.id'), nullable=False,
         info=dict(description="ID of the location this area is part of"))
-    internal_id = Column(Integer, nullable=False,
+    game_index = Column(Integer, nullable=False,
         info=dict(description="ID the games ude for this area"))
     identifier = Column(Unicode(64), nullable=True,
         info=dict(description="An identifier", format='identifier'))
 
+create_translation_table('location_area_prose', LocationArea, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
 class LocationAreaEncounterRate(TableBase):
     # XXX: What's this exactly? Someone add the docstring & revise the descriptions
     __tablename__ = 'location_area_encounter_rates'
@@ -590,15 +640,15 @@ class LocationAreaEncounterRate(TableBase):
     rate = Column(Integer, nullable=True,
         info=dict(description="The encounter rate"))  # units?
 
-class LocationInternalID(TableBase):
+class LocationGameIndex(TableBase):
     u"""IDs the games use internally for locations
     """
-    __tablename__ = 'location_internal_ids'
+    __tablename__ = 'location_game_indices'
     location_id = Column(Integer, ForeignKey('locations.id'), nullable=False, primary_key=True,
         info=dict(description="Database ID of the locaion"))
     generation_id = Column(Integer, ForeignKey('generations.id'), nullable=False, primary_key=True,
         info=dict(description="ID of the generation this entry to"))
-    internal_id = Column(Integer, nullable=False,
+    game_index = Column(Integer, nullable=False,
         info=dict(description="Internal game ID of the location"))
 
 class Machine(TableBase):
@@ -620,7 +670,7 @@ class Machine(TableBase):
         """
         return self.machine_number >= 100
 
-class MoveBattleStyle(TableBase, UnofficiallyNamed):
+class MoveBattleStyle(TableBase):
     u"""A battle style of a move"""  # XXX: Explain better
     __tablename__ = 'move_battle_styles'
     __singlename__ = 'move_battle_style'
@@ -629,7 +679,12 @@ class MoveBattleStyle(TableBase, UnofficiallyNamed):
     identifier = Column(Unicode(8), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
-class MoveEffectCategory(TableBase, UnofficiallyNamed):
+create_translation_table('move_battle_style_prose', MoveBattleStyle, 'prose',
+    name = Column(Unicode(8), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
+class MoveEffectCategory(TableBase):
     u"""Category of a move effect
     """
     __tablename__ = 'move_effect_categories'
@@ -641,6 +696,11 @@ class MoveEffectCategory(TableBase, UnofficiallyNamed):
     can_affect_user = Column(Boolean, nullable=False,
         info=dict(description="Set if the user can be affected"))
 
+create_translation_table('move_effect_category_prose', MoveEffectCategory, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+)
+
 class MoveEffectCategoryMap(TableBase):
     u"""Maps a move effect category to a move effect
     """
@@ -652,7 +712,7 @@ class MoveEffectCategoryMap(TableBase):
     affects_user = Column(Boolean, primary_key=True, nullable=False,
         info=dict(description="Set if the user is affected"))
 
-class MoveDamageClass(TableBase, UnofficiallyNamed):
+class MoveDamageClass(TableBase):
     u"""Any of the damage classes moves can have, i.e. physical, special, or non-damaging.
     """
     __tablename__ = 'move_damage_classes'
@@ -661,8 +721,13 @@ class MoveDamageClass(TableBase, UnofficiallyNamed):
         info=dict(description="A numeric ID"))
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description="An identifier", format='identifier'))
-    description = ProseColumn(Unicode(64), plural='descriptions', nullable=False,
-        info=dict(description="A description of the class", format='plaintext'))
+
+create_translation_table('move_damage_class_prose', MoveDamageClass, 'prose',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(Unicode(64), nullable=False,
+        info=dict(description="A description of the class", format='plaintext')),
+)
 
 class MoveEffect(TableBase):
     u"""An effect of a move
@@ -671,10 +736,13 @@ class MoveEffect(TableBase):
     __singlename__ = 'move_effect'
     id = Column(Integer, primary_key=True, nullable=False,
         info=dict(description="A numeric ID"))
-    short_effect = ProseColumn(Unicode(256), plural='short_effects', nullable=False,
-        info=dict(description="A short summary of the effect", format='plaintext'))
-    effect = ProseColumn(Unicode(5120), plural='effects', nullable=False,
-        info=dict(description="A detailed description of the effect", format='plaintext'))
+
+create_translation_table('move_effect_prose', MoveEffect, 'prose',
+    short_effect = Column(Unicode(256), nullable=False,
+        info=dict(description="A short summary of the effect", format='plaintext')),
+    effect = Column(Unicode(5120), nullable=False,
+        info=dict(description="A detailed description of the effect", format='plaintext')),
+)
 
 class MoveEffectChangelog(TableBase):
     """History of changes to move effects across main game versions."""
@@ -686,14 +754,17 @@ class MoveEffectChangelog(TableBase):
         info=dict(description="The ID of the effect that changed"))
     changed_in_version_group_id = Column(Integer, ForeignKey('version_groups.id'), nullable=False,
         info=dict(description="The ID of the version group in which the effect changed"))
-    effect = ProseColumn(markdown.MarkdownColumn(512), plural='effects', nullable=False,
-        info=dict(description="A description of the old behavior", format='markdown'))
 
     __table_args__ = (
         UniqueConstraint(effect_id, changed_in_version_group_id),
         {},
     )
 
+create_translation_table('move_effect_changelog_prose', MoveEffectChangelog, 'prose',
+    effect = Column(markdown.MarkdownColumn(512), nullable=False,
+        info=dict(description="A description of the old behavior", format='markdown')),
+)
+
 class MoveFlag(TableBase):
     u"""Maps a move flag to a move
     """
@@ -705,7 +776,7 @@ class MoveFlag(TableBase):
     move_flag_type_id = Column(Integer, ForeignKey('move_flag_types.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description="ID of the flag"))
 
-class MoveFlagType(TableBase, UnofficiallyNamed):
+class MoveFlagType(TableBase):
     u"""A Move attribute such as "snatchable" or "contact".
     """
     __tablename__ = 'move_flag_types'
@@ -714,10 +785,15 @@ class MoveFlagType(TableBase, UnofficiallyNamed):
         info=dict(description="A numeric ID"))
     identifier = Column(Unicode(32), nullable=False,
         info=dict(description="A short identifier for the flag", format='identifier'))
-    description = ProseColumn(markdown.MarkdownColumn(128), plural='descriptions', nullable=False,
-        info=dict(description="A short description of the flag", format='markdown'))
 
-class MoveFlavorText(TableBase, LanguageSpecific):
+create_translation_table('move_flag_type_prose', MoveFlagType, 'prose',
+    name = Column(Unicode(32), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(markdown.MarkdownColumn(128), nullable=False,
+        info=dict(description="A short description of the flag", format='markdown')),
+)
+
+class MoveFlavorText(TableBase):
     u"""In-game description of a move
     """
     __tablename__ = 'move_flavor_text'
@@ -725,6 +801,8 @@ class MoveFlavorText(TableBase, LanguageSpecific):
         info=dict(description="ID of the move"))
     version_group_id = Column(Integer, ForeignKey('version_groups.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description="ID of the version group this text appears in"))
+    language_id = Column(Integer, ForeignKey('languages.id'), primary_key=True, nullable=False,
+        info=dict(description="The language"))
     flavor_text = Column(Unicode(255), nullable=False,
         info=dict(description="The flavor text", official=True, format='gametext'))
 
@@ -758,7 +836,7 @@ class MoveMeta(TableBase):
     stat_chance = Column(Integer, nullable=False, index=True,
         info=dict(description="Chance to cause a stat change, in percent"))
 
-class MoveMetaAilment(TableBase, OfficiallyNamed):
+class MoveMetaAilment(TableBase):
     u"""Common status ailments moves can inflict on a single Pokémon, including
     major ailments like paralysis and minor ailments like trapping.
     """
@@ -769,14 +847,22 @@ class MoveMetaAilment(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(24), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
+create_translation_table('move_meta_ailment_names', MoveMetaAilment, 'names',
+    name = Column(Unicode(24), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class MoveMetaCategory(TableBase):
     u"""Very general categories that loosely group move effects."""
     __tablename__ = 'move_meta_categories'
     __singlename__ = 'move_meta_category'
     id = Column(Integer, primary_key=True, nullable=False,
         info=dict(description="A numeric ID"))
-    description = ProseColumn(Unicode(64), plural='descriptions', nullable=False,
-        info=dict(description="A description of the category"))
+
+create_translation_table('move_meta_category_prose', MoveMetaCategory, 'prose',
+    description = Column(Unicode(64), nullable=False,
+        info=dict(description="A description of the category")),
+)
 
 class MoveMetaStatChange(TableBase):
     u"""Stat changes moves (may) make."""
@@ -788,7 +874,7 @@ class MoveMetaStatChange(TableBase):
     change = Column(Integer, nullable=False, index=True,
         info=dict(description="Amount of increase/decrease, in stages"))
 
-class MoveTarget(TableBase, UnofficiallyNamed):
+class MoveTarget(TableBase):
     u"""Targetting or "range" of a move, e.g. "Affects all opponents" or "Affects user".
     """
     __tablename__ = 'move_targets'
@@ -797,10 +883,15 @@ class MoveTarget(TableBase, UnofficiallyNamed):
         info=dict(description="A numeric ID"))
     identifier = Column(Unicode(32), nullable=False,
         info=dict(description="An identifier", format='identifier'))
-    description = ProseColumn(Unicode(128), plural='descriptions', nullable=False,
-        info=dict(description="A description", format='plaintext'))
 
-class Move(TableBase, OfficiallyNamed):
+create_translation_table('move_target_prose', MoveTarget, 'prose',
+    name = Column(Unicode(32), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(Unicode(128), nullable=False,
+        info=dict(description="A description", format='plaintext')),
+)
+
+class Move(TableBase):
     u"""A Move: technique or attack a Pokémon can learn to use
     """
     __tablename__ = 'moves'
@@ -836,6 +927,12 @@ class Move(TableBase, OfficiallyNamed):
     super_contest_effect_id = Column(Integer, ForeignKey('super_contest_effects.id'), nullable=True,
         info=dict(description="ID of the move's Super Contest effect"))
 
+create_translation_table('move_names', 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."""
     __tablename__ = 'move_changelog'
@@ -857,7 +954,7 @@ class MoveChangelog(TableBase):
     effect_chance = Column(Integer, nullable=True,
         info=dict(description="Prior effect chance, or NULL if unchanged"))
 
-class Nature(TableBase, OfficiallyNamed):
+class Nature(TableBase):
     u"""A nature a Pokémon can have, such as Calm or Brave
     """
     __tablename__ = 'natures'
@@ -882,6 +979,11 @@ class Nature(TableBase, OfficiallyNamed):
         """
         return self.increased_stat_id == self.decreased_stat_id
 
+create_translation_table('nature_names', Nature, 'names',
+    name = Column(Unicode(8), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class NatureBattleStylePreference(TableBase):
     u"""Battle Palace move preference
 
@@ -909,7 +1011,7 @@ class NaturePokeathlonStat(TableBase):
     max_change = Column(Integer, nullable=False,
         info=dict(description="Maximum change"))
 
-class PokeathlonStat(TableBase, OfficiallyNamed):
+class PokeathlonStat(TableBase):
     u"""A Pokéathlon stat, such as "Stamina" or "Jump".
     """
     __tablename__ = 'pokeathlon_stats'
@@ -919,7 +1021,12 @@ class PokeathlonStat(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(8), nullable=False,
         info=dict(description="An identifier", format='identifier'))
 
-class Pokedex(TableBase, UnofficiallyNamed):
+create_translation_table('pokeathlon_stat_names', PokeathlonStat, 'names',
+    name = Column(Unicode(8), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
+class Pokedex(TableBase):
     u"""A collection of Pokémon species ordered in a particular way
     """
     __tablename__ = 'pokedexes'
@@ -930,10 +1037,15 @@ class Pokedex(TableBase, UnofficiallyNamed):
         info=dict(description=u"ID of the region this Pokédex is used in, or None if it's global"))
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
-    description = ProseColumn(Unicode(512), plural='descriptions', nullable=False,
-        info=dict(description=u"A longer description of the Pokédex", format='plaintext'))
 
-class Pokemon(TableBase, OfficiallyNamed):
+create_translation_table('pokedex_prose', Pokedex, 'prose',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(Unicode(512), nullable=False,
+        info=dict(description=u"A longer description of the Pokédex", format='plaintext')),
+)
+
+class Pokemon(TableBase):
     u"""A species of Pokémon.  The core to this whole mess.
     """
     __tablename__ = 'pokemon'
@@ -950,9 +1062,6 @@ class Pokemon(TableBase, OfficiallyNamed):
         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,
@@ -1058,6 +1167,14 @@ class Pokemon(TableBase, OfficiallyNamed):
         else:
             return None
 
+create_translation_table('pokemon_names', 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
     """
@@ -1074,7 +1191,7 @@ class PokemonAbility(TableBase):
     slot = Column(Integer, primary_key=True, nullable=False, autoincrement=False,
         info=dict(description=u"The ability slot, i.e. 1 or 2 for gen. IV"))
 
-class PokemonColor(TableBase, OfficiallyNamed):
+class PokemonColor(TableBase):
     u"""The "Pokédex color" of a Pokémon species. Usually based on the Pokémon's color.
     """
     __tablename__ = 'pokemon_colors'
@@ -1084,6 +1201,11 @@ class PokemonColor(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(6), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
 
+create_translation_table('pokemon_color_names', PokemonColor, 'names',
+    name = Column(Unicode(6), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class PokemonDexNumber(TableBase):
     u"""The number of a Pokémon in a particular Pokédex (e.g. Jigglypuff is #138 in Hoenn's 'dex)
     """
@@ -1142,7 +1264,7 @@ class PokemonEvolution(TableBase):
     trade_pokemon_id = Column(Integer, ForeignKey('pokemon.id'), nullable=True,
         info=dict(description=u"The ID of the Pokémon for which this Pokémon must be traded."))
 
-class PokemonFlavorText(TableBase, LanguageSpecific):
+class PokemonFlavorText(TableBase):
     u"""In-game Pokédex descrption of a Pokémon.
     """
     __tablename__ = 'pokemon_flavor_text'
@@ -1150,10 +1272,12 @@ class PokemonFlavorText(TableBase, LanguageSpecific):
         info=dict(description=u"ID of the Pokémon"))
     version_id = Column(Integer, ForeignKey('versions.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description=u"ID of the version that has this flavor text"))
+    language_id = Column(Integer, ForeignKey('languages.id'), primary_key=True, nullable=False,
+        info=dict(description="The language"))
     flavor_text = Column(Unicode(255), nullable=False,
         info=dict(description=u"ID of the version that has this flavor text", official=True, format='gametext'))
 
-class PokemonForm(TableBase, OfficiallyNamed):
+class PokemonForm(TableBase):
     u"""An individual form of a Pokémon.
 
     Pokémon that do not have separate forms are still given a single row to
@@ -1205,20 +1329,29 @@ class PokemonForm(TableBase, OfficiallyNamed):
         else:
             return self.form_base_pokemon.name
 
+create_translation_table('pokemon_form_names', PokemonForm, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class PokemonFormGroup(TableBase):
     u"""Information about a Pokémon's forms as a group."""
     __tablename__ = 'pokemon_form_groups'
     __singlename__ = 'pokemon_form_group'
     pokemon_id = Column(Integer, ForeignKey('pokemon.id'), primary_key=True, nullable=False, autoincrement=False,
         info=dict(description=u"ID of the base form Pokémon"))
-    term = ProseColumn(Unicode(16), plural='terms', nullable=True,
-        info=dict(description=u"The term for this Pokémon's forms, e.g. \"Cloak\" for Burmy or \"Forme\" for Deoxys.", official=True, format='plaintext'))
     is_battle_only = Column(Boolean, nullable=False,
         info=dict(description=u"Set iff the forms only change in battle"))
-    description = ProseColumn(markdown.MarkdownColumn(1024), plural='descriptions', nullable=False,
-        info=dict(description=u"Description of how the forms work", format='markdown'))
+# FIXME remooove
 PokemonFormGroup.id = PokemonFormGroup.pokemon_id
 
+create_translation_table('pokemon_form_group_prose', PokemonFormGroup, 'prose',
+    term = Column(Unicode(16), nullable=True,
+        info=dict(description=u"The term for this Pokémon's forms, e.g. \"Cloak\" for Burmy or \"Forme\" for Deoxys.", official=True, format='plaintext')),
+    description = Column(markdown.MarkdownColumn(1024), nullable=False,
+        info=dict(description=u"Description of how the forms work", format='markdown')),
+)
+
 class PokemonFormPokeathlonStat(TableBase):
     u"""A Pokémon form's performance in one Pokéathlon stat."""
     __tablename__ = 'pokemon_form_pokeathlon_stats'
@@ -1233,7 +1366,7 @@ class PokemonFormPokeathlonStat(TableBase):
     maximum_stat = Column(Integer, nullable=False, autoincrement=False,
         info=dict(description=u'The maximum value for this stat for this Pokémon form.'))
 
-class PokemonHabitat(TableBase, OfficiallyNamed):
+class PokemonHabitat(TableBase):
     u"""The habitat of a Pokémon, as given in the FireRed/LeafGreen version Pokédex
     """
     __tablename__ = 'pokemon_habitats'
@@ -1243,15 +1376,20 @@ class PokemonHabitat(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
 
-class PokemonInternalID(TableBase):
+create_translation_table('pokemon_habitat_names', PokemonHabitat, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
+class PokemonGameIndex(TableBase):
     u"""The number of a Pokémon a game uses internally
     """
-    __tablename__ = 'pokemon_internal_ids'
+    __tablename__ = 'pokemon_game_indices'
     pokemon_id = Column(Integer, ForeignKey('pokemon.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description=u"Database ID of the Pokémon"))
     generation_id = Column(Integer, ForeignKey('generations.id'), primary_key=True, autoincrement=False, nullable=False,
         info=dict(description=u"Database ID of the generation"))
-    internal_id = Column(Integer, nullable=False,
+    game_index = Column(Integer, nullable=False,
         info=dict(description=u"Internal ID the generation's games use for the Pokémon"))
 
 class PokemonItem(TableBase):
@@ -1289,7 +1427,7 @@ class PokemonMove(TableBase):
         {},
     )
 
-class PokemonMoveMethod(TableBase, UnofficiallyNamed):
+class PokemonMoveMethod(TableBase):
     u"""A method a move can be learned by, such as "Level up" or "Tutor".
     """
     __tablename__ = 'pokemon_move_methods'
@@ -1298,10 +1436,16 @@ class PokemonMoveMethod(TableBase, UnofficiallyNamed):
         info=dict(description=u"A numeric ID"))
     identifier = Column(Unicode(64), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
-    description = ProseColumn(Unicode(255), plural='descriptions', nullable=False,
-        info=dict(description=u"A detailed description of how the method works", format='plaintext'))
 
-class PokemonShape(TableBase, UnofficiallyNamed):
+create_translation_table('pokemon_move_method_prose', PokemonMoveMethod, 'prose',
+    name = Column(Unicode(64), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    description = Column(Unicode(255), nullable=False,
+        info=dict(description=u"A detailed description of how the method works", format='plaintext')),
+)
+
+
+class PokemonShape(TableBase):
     u"""The shape of a Pokémon's body, as used in generation IV Pokédexes.
     """
     __tablename__ = 'pokemon_shapes'
@@ -1310,8 +1454,13 @@ class PokemonShape(TableBase, UnofficiallyNamed):
         info=dict(description=u"A numeric ID"))
     identifier = Column(Unicode(24), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
-    awesome_name = ProseColumn(Unicode(16), plural='awesome_names', nullable=False,
-        info=dict(description=u"A splendiferous name of the body shape", format='plaintext'))
+
+create_translation_table('pokemon_shape_prose', PokemonShape, 'prose',
+    name = Column(Unicode(24), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=False)),
+    awesome_name = Column(Unicode(16), nullable=False,
+        info=dict(description=u"A splendiferous name of the body shape", format='plaintext')),
+)
 
 class PokemonStat(TableBase):
     u"""A stat value of a Pokémon
@@ -1337,7 +1486,7 @@ class PokemonType(TableBase):
     slot = Column(Integer, primary_key=True, nullable=False, autoincrement=False,
         info=dict(description=u"The type's slot, 1 or 2, used to sort types if there are two of them"))
 
-class Region(TableBase, OfficiallyNamed):
+class Region(TableBase):
     u"""Major areas of the world: Kanto, Johto, etc.
     """
     __tablename__ = 'regions'
@@ -1347,7 +1496,12 @@ class Region(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
 
-class Stat(TableBase, OfficiallyNamed):
+create_translation_table('region_names', Region, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
+class Stat(TableBase):
     u"""A Stat, such as Attack or Speed
     """
     __tablename__ = 'stats'
@@ -1359,6 +1513,11 @@ class Stat(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(16), nullable=False,
         info=dict(description=u"An identifier", format='identifier'))
 
+create_translation_table('stat_names', Stat, 'names',
+    name = Column(Unicode(16), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class StatHint(TableBase):
     u"""Flavor text for genes that appears in a Pokémon's summary.  Sometimes
     called "characteristics".
@@ -1371,8 +1530,11 @@ class StatHint(TableBase):
         info=dict(description=u"ID of the highest stat"))
     gene_mod_5 = Column(Integer, nullable=False, index=True,
         info=dict(description=u"Value of the highest stat modulo 5"))
-    message = TextColumn(Unicode(24), plural='messages', nullable=False, index=True, unique=True,
-        info=dict(description=u"The text displayed", official=True, format='plaintext'))
+
+create_translation_table('stat_hint_names', StatHint, 'names',
+    message = Column(Unicode(24), nullable=False, index=True,
+        info=dict(description=u"The text displayed", official=True, format='plaintext')),
+)
 
 class SuperContestCombo(TableBase):
     u"""Combo of two moves in a Super Contest.
@@ -1392,8 +1554,12 @@ class SuperContestEffect(TableBase):
         info=dict(description=u"This effect's unique ID."))
     appeal = Column(SmallInteger, nullable=False,
         info=dict(description=u"The number of hearts the user gains."))
-    flavor_text = ProseColumn(Unicode(64), plural='flavor_texts', nullable=False,
-        info=dict(description=u"A description of the effect.", format='plaintext'))
+
+create_translation_table('super_contest_effect_prose', SuperContestEffect, 'prose',
+    flavor_text = Column(Unicode(64), nullable=False,
+        info=dict(description=u"A description of the effect.", format='plaintext', official=True)),
+)
+
 
 class TypeEfficacy(TableBase):
     u"""The damage multiplier used when a move of a particular type damages a
@@ -1407,7 +1573,7 @@ class TypeEfficacy(TableBase):
     damage_factor = Column(Integer, nullable=False,
         info=dict(description=u"The multiplier, as a percentage of damage inflicted."))
 
-class Type(TableBase, OfficiallyNamed):
+class Type(TableBase):
     u"""Any of the elemental types Pokémon and moves can have."""
     __tablename__ = 'types'
     __singlename__ = 'type'
@@ -1420,6 +1586,11 @@ class Type(TableBase, OfficiallyNamed):
     damage_class_id = Column(Integer, ForeignKey('move_damage_classes.id'), nullable=True,
         info=dict(description=u"The ID of the damage class this type's moves had before Generation IV, null if not applicable (e.g. ???)."))
 
+create_translation_table('type_names', Type, 'names',
+    name = Column(Unicode(12), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 class VersionGroup(TableBase):
     u"""A group of versions, containing either two paired versions (such as Red
     and Blue) or a single game (such as Yellow.)
@@ -1440,7 +1611,7 @@ class VersionGroupRegion(TableBase):
     region_id = Column(Integer, ForeignKey('regions.id'), primary_key=True, nullable=False,
         info=dict(description=u"The ID of the region."))
 
-class Version(TableBase, OfficiallyNamed):
+class Version(TableBase):
     u"""An individual main-series Pokémon game."""
     __tablename__ = 'versions'
     __singlename__ = 'version'
@@ -1451,6 +1622,11 @@ class Version(TableBase, OfficiallyNamed):
     identifier = Column(Unicode(32), nullable=False,
         info=dict(description=u'And identifier', format='identifier'))
 
+create_translation_table('version_names', Version, 'names',
+    name = Column(Unicode(32), nullable=False, index=True,
+        info=dict(description="The name", format='plaintext', official=True)),
+)
+
 
 ### Relations down here, to avoid ordering problems
 Ability.changelog = relation(AbilityChangelog,
@@ -1459,33 +1635,11 @@ Ability.changelog = relation(AbilityChangelog,
 )
 Ability.flavor_text = relation(AbilityFlavorText, order_by=AbilityFlavorText.version_group_id, backref='ability')
 Ability.generation = relation(Generation, backref='abilities')
-Ability.all_pokemon = relation(Pokemon,
-    secondary=PokemonAbility.__table__,
-    order_by=Pokemon.order,
-    back_populates='all_abilities',
-)
-Ability.pokemon = relation(Pokemon,
-    secondary=PokemonAbility.__table__,
-    primaryjoin=and_(
-        PokemonAbility.ability_id == Ability.id,
-        PokemonAbility.is_dream == False
-    ),
-    order_by=Pokemon.order,
-    back_populates='abilities',
-)
-Ability.dream_pokemon = relation(Pokemon,
-    secondary=PokemonAbility.__table__,
-    primaryjoin=and_(
-        PokemonAbility.ability_id == Ability.id,
-        PokemonAbility.is_dream == True
-    ),
-    order_by=Pokemon.order,
-    back_populates='dream_ability',
-)
 
 AbilityChangelog.changed_in = relation(VersionGroup, backref='ability_changelog')
 
 AbilityFlavorText.version_group = relation(VersionGroup)
+AbilityFlavorText.language = relation(Language)
 
 Berry.berry_firmness = relation(BerryFirmness, backref='berries')
 Berry.firmness = association_proxy('berry_firmness', 'name')
@@ -1516,7 +1670,6 @@ 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')
 
 Experience.growth_rate = relation(GrowthRate, backref='experience_table')
 
@@ -1539,9 +1692,10 @@ ItemCategory.items = relation(Item, order_by=Item.identifier)
 ItemCategory.pocket = relation(ItemPocket)
 
 ItemFlavorText.version_group = relation(VersionGroup)
+ItemFlavorText.language = relation(Language)
 
-ItemInternalID.item = relation(Item, backref='internal_ids')
-ItemInternalID.generation = relation(Generation)
+ItemGameIndex.item = relation(Item, backref='game_indices')
+ItemGameIndex.generation = relation(Generation)
 
 ItemPocket.categories = relation(ItemCategory, order_by=ItemCategory.identifier)
 
@@ -1549,8 +1703,8 @@ Location.region = relation(Region, backref='locations')
 
 LocationArea.location = relation(Location, backref='areas')
 
-LocationInternalID.location = relation(Location, backref='internal_ids')
-LocationInternalID.generation = relation(Generation)
+LocationGameIndex.location = relation(Location, backref='game_indices')
+LocationGameIndex.generation = relation(Generation)
 
 Machine.item = relation(Item)
 Machine.version_group = relation(VersionGroup)
@@ -1576,12 +1730,22 @@ 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, backref='moves')
+
+Move.effect = markdown.MoveEffectProperty('effect')
+Move.effect_map = markdown.MoveEffectProperty('effect_map')
+Move.short_effect = markdown.MoveEffectProperty('short_effect')
+Move.short_effect_map = markdown.MoveEffectProperty('short_effect_map')
 
 MoveChangelog.changed_in = relation(VersionGroup, backref='move_changelog')
 MoveChangelog.move_effect = relation(MoveEffect, backref='move_changelog')
 MoveChangelog.type = relation(Type, backref='move_changelog')
 
+MoveChangelog.effect = markdown.MoveEffectProperty('effect')
+MoveChangelog.effect_map = markdown.MoveEffectProperty('effect_map')
+MoveChangelog.short_effect = markdown.MoveEffectProperty('short_effect')
+MoveChangelog.short_effect_map = markdown.MoveEffectProperty('short_effect_map')
+
 MoveEffect.category_map = relation(MoveEffectCategoryMap)
 MoveEffect.categories = association_proxy('category_map', 'category')
 MoveEffect.changelog = relation(MoveEffectChangelog,
@@ -1595,6 +1759,7 @@ MoveEffectChangelog.changed_in = relation(VersionGroup, backref='move_effect_cha
 MoveFlag.flag = relation(MoveFlagType)
 
 MoveFlavorText.version_group = relation(VersionGroup)
+MoveFlavorText.language = relation(Language)
 
 MoveMeta.category = relation(MoveMetaCategory, backref='move_meta')
 MoveMeta.ailment = relation(MoveMetaAilment, backref='move_meta')
@@ -1619,11 +1784,14 @@ 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, backref='pokedex')
 
 Pokemon.all_abilities = relation(Ability,
     secondary=PokemonAbility.__table__,
     order_by=PokemonAbility.slot,
+    backref=backref('all_pokemon',
+        order_by=Pokemon.order,
+    ),
 )
 Pokemon.abilities = relation(Ability,
     secondary=PokemonAbility.__table__,
@@ -1632,6 +1800,9 @@ Pokemon.abilities = relation(Ability,
         PokemonAbility.is_dream == False,
     ),
     order_by=PokemonAbility.slot,
+    backref=backref('pokemon',
+        order_by=Pokemon.order,
+    ),
 )
 Pokemon.dream_ability = relation(Ability,
     secondary=PokemonAbility.__table__,
@@ -1640,6 +1811,9 @@ Pokemon.dream_ability = relation(Ability,
         PokemonAbility.is_dream == True,
     ),
     uselist=False,
+    backref=backref('dream_pokemon',
+        order_by=Pokemon.order,
+    ),
 )
 Pokemon.pokemon_color = relation(PokemonColor, backref='pokemon')
 Pokemon.color = association_proxy('pokemon_color', 'name')
@@ -1647,7 +1821,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, backref=backref('pokemon', order_by=Pokemon.order))
 Pokemon.child_pokemon = relation(Pokemon,
     primaryjoin=Pokemon.id==PokemonEvolution.from_pokemon_id,
     secondary=PokemonEvolution.__table__,
@@ -1667,9 +1841,11 @@ Pokemon.items = relation(PokemonItem, backref='pokemon')
 Pokemon.generation = relation(Generation, backref='pokemon')
 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')
+Pokemon.types = relation(Type,
+    secondary=PokemonType.__table__,
+    order_by=PokemonType.slot.asc(),
+    backref=backref('pokemon', order_by=Pokemon.order),
+)
 
 PokemonDexNumber.pokedex = relation(Pokedex)
 
@@ -1709,6 +1885,7 @@ PokemonEvolution.trade_pokemon = relation(Pokemon,
 )
 
 PokemonFlavorText.version = relation(Version)
+PokemonFlavorText.language = relation(Language)
 
 PokemonForm.form_base_pokemon = relation(Pokemon, primaryjoin=PokemonForm.form_base_pokemon_id==Pokemon.id)
 PokemonForm.unique_pokemon = relation(Pokemon, backref=backref('unique_form', uselist=False),
@@ -1765,236 +1942,10 @@ Type.target_efficacies = relation(TypeEfficacy,
 
 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')
-Type.moves = relation(Move, back_populates='type', order_by=Move.id)
 
-Version.version_group = relation(VersionGroup, back_populates='versions')
 Version.generation = association_proxy('version_group', 'generation')
 
-VersionGroup.versions = relation(Version, order_by=Version.id, back_populates='version_group')
+VersionGroup.versions = relation(Version, order_by=Version.id, backref='version_group')
 VersionGroup.generation = relation(Generation, backref='version_groups')
 VersionGroup.version_group_regions = relation(VersionGroupRegion, backref='version_group')
 VersionGroup.regions = association_proxy('version_group_regions', 'region')
-VersionGroup.pokedex = relation(Pokedex, back_populates='version_groups')
-
-
-### Add name tables
-for table in list(table_classes):
-    if issubclass(table, OfficiallyNamed):
-        cls = TextColumn
-        info=dict(description="The name", format='plaintext', official=True)
-    elif issubclass(table, UnofficiallyNamed):
-        cls = ProseColumn
-        info=dict(description="The name", format='plaintext', official=False)
-    else:
-        continue
-    table.name = cls(Unicode(class_mapper(table).c.identifier.type.length),
-        plural='names', index=True, nullable=False, info=info)
-
-### Add text/prose tables
-
-default_lang = u'en'
-
-def makeTextTable(object_table, name_plural, name_singular, columns, lazy):
-    # With "Language", we'd have two language_id. So, rename one to 'lang'
-    safe_name = object_table.__singlename__
-    if safe_name == 'language':
-        safe_name = 'lang'
-
-    tablename = object_table.__singlename__ + '_' + name_plural
-    singlename = object_table.__singlename__ + '_' + name_singular
-
-    class Strings(object):
-        __tablename__ = tablename
-        __singlename__ = singlename
-        _attrname = name_plural
-        _language_identifier = association_proxy('language', 'identifier')
-
-    for name, plural, column in columns:
-        column.name = name
-        if not column.nullable:
-            # A Python side default value, so that the strings can be set
-            # one by one without the DB complaining about missing values
-            column.default = ColumnDefault(u'')
-
-    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, index=True, nullable=False),
-            *(column for name, plural, column in columns)
-        )
-
-    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(object_table, name_plural, relation(
-            Strings,
-            primaryjoin=(object_table.id == Strings.object_id),
-            backref=safe_name,
-            collection_class=attribute_mapped_collection('language'),
-            lazy=lazy,
-        ))
-    Strings.object = getattr(Strings, safe_name)
-
-    # Link the tables themselves, so we can get them if needed
-    Strings.object_table = object_table
-    setattr(object_table, name_singular + '_table', Strings)
-
-    for colname, pluralname, column in columns:
-        # Provide a property with all the names, and an English accessor
-        # for backwards compatibility
-        setattr(object_table, pluralname, StringProperty(
-                object_table, Strings, colname,
-            ))
-        setattr(object_table, colname, DefaultLangProperty(pluralname))
-
-        if colname == 'name':
-            object_table.name_table = Strings
-
-    return Strings
-
-class StringProperty(object):
-    def __init__(self, cls, stringclass, colname):
-        self.cls = cls
-        self.colname = colname
-        self.stringclass = stringclass
-
-    def __get__(self, instance, cls):
-        if instance:
-            return StringMapping(instance, self)
-        else:
-            return self
-
-    def __getitem__(self, lang):
-        return StringExpression(self, lang)
-
-    def __str__(self):
-        return '<StringDict %s.%s>' % (self.cls, self.colname)
-
-class StringMapping(collections.MutableMapping):
-    def __init__(self, instance, prop):
-        self.stringclass = prop.stringclass
-        self.instance = instance
-        self.strings = getattr(instance, prop.stringclass._attrname)
-        self.colname = prop.colname
-
-    def __len__(self):
-        return len(self.strings)
-
-    def __iter__(self):
-        return iter(self.strings)
-
-    def __contains__(self, lang):
-        return lang in self.strings
-
-    def __getitem__(self, lang):
-        return getattr(self.strings[lang], self.colname)
-
-    def __setitem__(self, lang, value):
-        try:
-            # Modifying an existing row
-            row = self.strings[lang]
-        except KeyError:
-            # We need do add a whole row for the language
-            row = self.stringclass()
-            row.object_id = self.instance.id
-            session = object_session(self.instance)
-            if isinstance(lang, basestring):
-                lang = session.query(Language).filter_by(
-                        identifier=lang).one()
-            row.language = lang
-            self.strings[lang] = row
-            session.add(row)
-        return setattr(row, self.colname, value)
-
-    def __delitem__(self, lang):
-        raise NotImplementedError('Cannot delete a single string. '
-                'Perhaps you wan to delete all of %s.%s?' %
-                (self.instance, self.stringclass._attrname)
-            )
-
-class StringExpression(ColumnOperators):
-    def __init__(self, prop, lang):
-        self.prop = prop
-        self.column = getattr(prop.stringclass, prop.colname)
-        self.lang_column = prop.stringclass._language_identifier
-        if isinstance(lang, basestring):
-            self.lang = lang
-        else:
-            self.lang = lang.identifier
-
-    def operate(self, op, *values, **kwargs):
-        return getattr(self.prop.cls, self.prop.stringclass._attrname).any(and_(
-                self.lang_column == self.lang,
-                op(self.column, *values, **kwargs),
-            ))
-
-class DefaultLangProperty(object):
-    def __init__(self, colname):
-        self.colname = colname
-
-    def __get__(self, instance, cls):
-        if instance:
-            return getattr(instance, self.colname)[default_lang]
-        else:
-            return getattr(cls, self.colname)[default_lang]
-
-    def __set__(self, instance, value):
-        getattr(instance, self.colname)[default_lang] = value
-
-    def __delete__(self, instance):
-        del getattr(instance, self.colname)[default_lang]
-
-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(spec)
-        elif isinstance(column, ProseColumn):
-            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)
-    if prose_columns:
-        string_table = makeTextTable(table, 'prose', 'prose', prose_columns, lazy=True)
-
-### Add language relations
-for table in list(table_classes):
-    if issubclass(table, LanguageSpecific):
-        table.language = relation(Language, primaryjoin=table.language_id == Language.id)
-
-Move.effect = DefaultLangProperty('effects')
-Move.effects = markdown.MoveEffectsProperty('effect')
-Move.short_effect = DefaultLangProperty('short_effects')
-Move.short_effects = markdown.MoveEffectsProperty('short_effect')
-
-MoveChangelog.effect = DefaultLangProperty('effects')
-MoveChangelog.effects = markdown.MoveEffectsProperty('effect')
-MoveChangelog.short_effect = DefaultLangProperty('short_effects')
-MoveChangelog.short_effects = markdown.MoveEffectsProperty('short_effect')
index 1b99065..a0946cf 100644 (file)
@@ -225,7 +225,7 @@ class PokedexLookup(object):
                 # Some things also have other languages' names
                 # XXX other language form names..?
                 seen = set()
-                for language, name in getattr(row, 'names', []).items():
+                for language, name in getattr(row, 'name_map', {}).items():
                     if name in seen:
                         # Don't add the name again as a different
                         # language; no point and it makes spell results
index 962377f..5b52539 100644 (file)
@@ -128,8 +128,8 @@ class SaveFilePokemon(object):
 
         self._held_item = None
         if st.held_item_id:
-            self._held_item = session.query(tables.ItemInternalID) \
-                .filter_by(generation_id = 4, internal_id = st.held_item_id).one().item
+            self._held_item = session.query(tables.ItemGameIndex) \
+                .filter_by(generation_id = 4, game_index = st.held_item_id).one().item
 
         self._stats = []
         for pokemon_stat in self._pokemon.stats:
@@ -173,19 +173,19 @@ class SaveFilePokemon(object):
             pokeball_id = st.hgss_pokeball - 17 + 492
         else:
             pokeball_id = st.dppt_pokeball
-        self._pokeball = session.query(tables.ItemInternalID) \
-            .filter_by(generation_id = 4, internal_id = pokeball_id).one().item
+        self._pokeball = session.query(tables.ItemGameIndex) \
+            .filter_by(generation_id = 4, game_index = pokeball_id).one().item
 
         egg_loc_id = st.pt_egg_location_id or st.dp_egg_location_id
         met_loc_id = st.pt_met_location_id or st.dp_met_location_id
 
         self._egg_location = None
         if egg_loc_id:
-            self._egg_location = session.query(tables.LocationInternalID) \
-                .filter_by(generation_id = 4, internal_id = egg_loc_id).one().location
+            self._egg_location = session.query(tables.LocationGameIndex) \
+                .filter_by(generation_id = 4, game_index = egg_loc_id).one().location
 
-        self._met_location = session.query(tables.LocationInternalID) \
-            .filter_by(generation_id = 4, internal_id = met_loc_id).one().location
+        self._met_location = session.query(tables.LocationGameIndex) \
+            .filter_by(generation_id = 4, game_index = met_loc_id).one().location
 
     @property
     def species(self):
index f1583a7..88ee1e5 100644 (file)
@@ -1,9 +1,13 @@
 # encoding: utf8
 from nose.tools import *
 import unittest
-from sqlalchemy.orm import class_mapper
+from sqlalchemy import Column, Integer, String, create_engine
+from sqlalchemy.orm import class_mapper, joinedload, sessionmaker
+from sqlalchemy.orm.session import Session
+from sqlalchemy.ext.declarative import declarative_base
 
 from pokedex.db import tables, markdown
+from pokedex.db.multilang import create_translation_table
 
 def test_variable_names():
     """We want pokedex.db.tables to export tables using the class name"""
@@ -22,6 +26,126 @@ def test_variable_names():
     for table in tables.table_classes:
         assert getattr(tables, table.__name__) is table
 
+def test_i18n_table_creation():
+    """Creates and manipulates a magical i18n table, completely independent of
+    the existing schema and data.  Makes sure that the expected behavior of the
+    various proxies and columns works.
+    """
+    Base = declarative_base()
+    engine = create_engine("sqlite:///:memory:", echo=True)
+
+    Base.metadata.bind = engine
+
+    # Need this for the foreign keys to work!
+    class Language(Base):
+        __tablename__ = 'languages'
+        id = Column(Integer, primary_key=True, nullable=False)
+        identifier = Column(String(2), nullable=False, unique=True)
+
+    class Foo(Base):
+        __tablename__ = 'foos'
+        __singlename__ = 'foo'
+        id = Column(Integer, primary_key=True, nullable=False)
+
+    FooText = create_translation_table('foo_text', Foo,
+        language_class=Language,
+        name = Column(String(100)),
+    )
+
+    class FauxSession(Session):
+        def execute(self, clause, params=None, *args, **kwargs):
+            if not params:
+                params = {}
+            params.setdefault('_default_language', 'en')
+            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_=FauxSession)()
+
+    # Create some languages and foos to bind together
+    lang_en = Language(identifier='en')
+    sess.add(lang_en)
+    lang_jp = Language(identifier='jp')
+    sess.add(lang_jp)
+    lang_ru = Language(identifier='ru')
+    sess.add(lang_ru)
+
+    foo = Foo()
+    sess.add(foo)
+
+    # Commit so the above get primary keys filled in
+    sess.commit()
+
+    # Give our foo some names, as directly as possible
+    foo_text = FooText()
+    foo_text.object_id = foo.id
+    foo_text.language_id = lang_en.id
+    foo_text.name = 'english'
+    sess.add(foo_text)
+
+    foo_text = FooText()
+    foo_text.object_id = foo.id
+    foo_text.language_id = lang_jp.id
+    foo_text.name = 'nihongo'
+    sess.add(foo_text)
+
+    # Commit!  This will expire all of the above.
+    sess.commit()
+
+    ### Test 1: re-fetch foo and check its attributes
+    foo = sess.query(Foo).params(_default_language='en').one()
+
+    # Dictionary of language identifiers => names
+    assert foo.name_map[lang_en] == 'english'
+    assert foo.name_map[lang_jp] == 'nihongo'
+
+    # Default language, currently English
+    assert foo.name == 'english'
+
+    sess.expire_all()
+
+    ### Test 2: querying by default language name should work
+    foo = sess.query(Foo).filter_by(name='english').one()
+
+    assert foo.name == 'english'
+
+    sess.expire_all()
+
+    ### Test 3: joinedload on the default name should appear to work
+    # THIS SHOULD WORK SOMEDAY
+    #    .options(joinedload(Foo.name)) \
+    foo = sess.query(Foo) \
+        .options(joinedload(Foo.foo_text_local)) \
+        .one()
+
+    assert foo.name == 'english'
+
+    sess.expire_all()
+
+    ### Test 4: joinedload on all the names should appear to work
+    # THIS SHOULD ALSO WORK SOMEDAY
+    #    .options(joinedload(Foo.name_map)) \
+    foo = sess.query(Foo) \
+        .options(joinedload(Foo.foo_text)) \
+        .one()
+
+    assert foo.name_map[lang_en] == 'english'
+    assert foo.name_map[lang_jp] == 'nihongo'
+
+    sess.expire_all()
+
+    ### Test 5: Mutating the dict collection should work
+    foo = sess.query(Foo).one()
+
+    foo.name_map[lang_en] = 'different english'
+    foo.name_map[lang_ru] = 'new russian'
+
+    sess.commit()
+
+    assert foo.name_map[lang_en] == 'different english'
+    assert foo.name_map[lang_ru] == 'new russian'
+
 def test_texts():
     """Check DB schema for integrity of text columns & translations.