aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--mediagoblin/app.py11
-rw-r--r--mediagoblin/db/migrations.py57
-rw-r--r--mediagoblin/db/models.py8
-rw-r--r--mediagoblin/db/open.py34
-rw-r--r--mediagoblin/db/util.py183
-rw-r--r--mediagoblin/gmg_commands/migrate.py40
-rw-r--r--mediagoblin/tests/test_migrations.py402
-rw-r--r--mediagoblin/tests/tools.py32
8 files changed, 688 insertions, 79 deletions
diff --git a/mediagoblin/app.py b/mediagoblin/app.py
index 147db09c..7a6a1f33 100644
--- a/mediagoblin/app.py
+++ b/mediagoblin/app.py
@@ -22,6 +22,7 @@ from webob import Request, exc
from mediagoblin import routing, util, storage
from mediagoblin.db.open import setup_connection_and_db_from_config
+from mediagoblin.db.util import MigrationManager
from mediagoblin.mg_globals import setup_globals
from mediagoblin.init.celery import setup_celery_from_config
from mediagoblin.init import get_jinja_loader, get_staticdirector, \
@@ -59,6 +60,16 @@ class MediaGoblinApp(object):
self.connection, self.db = setup_connection_and_db_from_config(
app_config)
+ # Init the migration number if necessary
+ migration_manager = MigrationManager(self.db)
+ migration_manager.install_migration_version_if_missing()
+
+ # Tiny hack to warn user if our migration is out of date
+ if not migration_manager.database_at_latest_migration():
+ print (
+ "*WARNING:* Your migrations are out of date, "
+ "maybe run ./bin/gmg migrate?")
+
# Get the template environment
self.template_loader = get_jinja_loader(
app_config.get('user_template_path'))
diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py
index 712f8ab4..683a57f3 100644
--- a/mediagoblin/db/migrations.py
+++ b/mediagoblin/db/migrations.py
@@ -14,56 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from mediagoblin.util import cleaned_markdown_conversion
+from mediagoblin.db.util import RegisterMigration
-from mongokit import DocumentMigration
+# Please see mediagoblin/tests/test_migrations.py for some examples of
+# basic migrations.
-
-class MediaEntryMigration(DocumentMigration):
- def allmigration01_uploader_to_reference(self):
- """
- Old MediaEntry['uploader'] accidentally embedded the User instead
- of referencing it. Fix that!
- """
- # uploader is an associative array
- self.target = {'uploader': {'$type': 3}}
- if not self.status:
- for doc in self.collection.find(self.target):
- self.update = {
- '$set': {
- 'uploader': doc['uploader']['_id']}}
- self.collection.update(
- self.target, self.update, multi=True, safe=True)
-
- def allmigration02_add_description_html(self):
- """
- Now that we can have rich descriptions via Markdown, we should
- update all existing entries to record the rich description versions.
- """
- self.target = {'description_html': {'$exists': False},
- 'description': {'$exists': True}}
-
- if not self.status:
- for doc in self.collection.find(self.target):
- self.update = {
- '$set': {
- 'description_html': cleaned_markdown_conversion(
- doc['description'])}}
-
-class UserMigration(DocumentMigration):
- def allmigration01_add_bio_and_url_profile(self):
- """
- User can elaborate profile with home page and biography
- """
- self.target = {'url': {'$exists': False},
- 'bio': {'$exists': False}}
- if not self.status:
- for doc in self.collection.find(self.target):
- self.update = {
- '$set': {'url': '',
- 'bio': ''}}
- self.collection.update(
- self.target, self.update, multi=True, safe=True)
-
-
-MIGRATE_CLASSES = ['MediaEntry', 'User']
+# @RegisterMigration(1)
+# def do_something(database):
+# pass
diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py
index 279cb9f2..918dee0e 100644
--- a/mediagoblin/db/models.py
+++ b/mediagoblin/db/models.py
@@ -16,12 +16,11 @@
import datetime, uuid
-from mongokit import Document, Set
+from mongokit import Document
from mediagoblin import util
from mediagoblin.auth import lib as auth_lib
from mediagoblin import mg_globals
-from mediagoblin.db import migrations
from mediagoblin.db.util import ASCENDING, DESCENDING, ObjectId
###################
@@ -59,8 +58,6 @@ class User(Document):
'verification_key': lambda: unicode(uuid.uuid4()),
'is_admin': False}
- migration_handler = migrations.UserMigration
-
def check_login(self, password):
"""
See if a user can login with this password
@@ -106,8 +103,6 @@ class MediaEntry(Document):
'created': datetime.datetime.utcnow,
'state': u'unprocessed'}
- migration_handler = migrations.MediaEntryMigration
-
def get_comments(self):
return self.db.MediaComment.find({
'media_entry': self['_id']}).sort('created', DESCENDING)
@@ -196,6 +191,7 @@ class MediaComment(Document):
def author(self):
return self.db.User.find_one({'_id': self['author']})
+
REGISTER_MODELS = [
MediaEntry,
User,
diff --git a/mediagoblin/db/open.py b/mediagoblin/db/open.py
index b6987677..e5fde6f9 100644
--- a/mediagoblin/db/open.py
+++ b/mediagoblin/db/open.py
@@ -14,24 +14,42 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import pymongo
import mongokit
from paste.deploy.converters import asint
from mediagoblin.db import models
-def connect_database_from_config(app_config):
- """Connect to the main database, take config from app_config"""
+def connect_database_from_config(app_config, use_pymongo=False):
+ """
+ Connect to the main database, take config from app_config
+
+ Optionally use pymongo instead of mongokit for the connection.
+ """
port = app_config.get('db_port')
if port:
port = asint(port)
- connection = mongokit.Connection(
- app_config.get('db_host'), port)
+
+ if use_pymongo:
+ connection = pymongo.Connection(
+ app_config.get('db_host'), port)
+ else:
+ connection = mongokit.Connection(
+ app_config.get('db_host'), port)
return connection
-def setup_connection_and_db_from_config(app_config):
- connection = connect_database_from_config(app_config)
+
+def setup_connection_and_db_from_config(app_config, use_pymongo=False):
+ """
+ Setup connection and database from config.
+
+ Optionally use pymongo instead of mongokit.
+ """
+ connection = connect_database_from_config(app_config, use_pymongo)
database_path = app_config['db_name']
db = connection[database_path]
- models.register_models(connection)
- # Could configure indexes here on db
+
+ if not use_pymongo:
+ models.register_models(connection)
+
return (connection, db)
diff --git a/mediagoblin/db/util.py b/mediagoblin/db/util.py
index 37e6586f..0f3220d2 100644
--- a/mediagoblin/db/util.py
+++ b/mediagoblin/db/util.py
@@ -37,6 +37,11 @@ from mongokit import ObjectId
from mediagoblin.db.indexes import ACTIVE_INDEXES, DEPRECATED_INDEXES
+################
+# Indexing tools
+################
+
+
def add_new_indexes(database, active_indexes=ACTIVE_INDEXES):
"""
Add any new indexes to the database.
@@ -106,3 +111,181 @@ def remove_deprecated_indexes(database, deprecated_indexes=DEPRECATED_INDEXES):
indexes_removed.append((collection_name, index_name))
return indexes_removed
+
+
+#################
+# Migration tools
+#################
+
+# The default migration registry...
+#
+# Don't set this yourself! RegisterMigration will automatically fill
+# this with stuff via decorating methods in migrations.py
+
+class MissingCurrentMigration(Exception): pass
+
+
+MIGRATIONS = {}
+
+
+class RegisterMigration(object):
+ """
+ Tool for registering migrations
+
+ Call like:
+
+ @RegisterMigration(33)
+ def update_dwarves(database):
+ [...]
+
+ This will register your migration with the default migration
+ registry. Alternately, to specify a very specific
+ migration_registry, you can pass in that as the second argument.
+
+ Note, the number of your migration should NEVER be 0 or less than
+ 0. 0 is the default "no migrations" state!
+ """
+ def __init__(self, migration_number, migration_registry=MIGRATIONS):
+ assert migration_number > 0, "Migration number must be > 0!"
+ assert not migration_registry.has_key(migration_number), \
+ "Duplicate migration numbers detected! That's not allowed!"
+
+ self.migration_number = migration_number
+ self.migration_registry = migration_registry
+
+ def __call__(self, migration):
+ self.migration_registry[self.migration_number] = migration
+ return migration
+
+
+class MigrationManager(object):
+ """
+ Migration handling tool.
+
+ Takes information about a database, lets you update the database
+ to the latest migrations, etc.
+ """
+ def __init__(self, database, migration_registry=MIGRATIONS):
+ """
+ Args:
+ - database: database we're going to migrate
+ - migration_registry: where we should find all migrations to
+ run
+ """
+ self.database = database
+ self.migration_registry = migration_registry
+ self._sorted_migrations = None
+
+ def _ensure_current_migration_record(self):
+ """
+ If there isn't a database[u'app_metadata'] mediagoblin entry
+ with the 'current_migration', throw an error.
+ """
+ if self.database_current_migration() is None:
+ raise MissingCurrentMigration(
+ "Tried to call function which requires "
+ "'current_migration' set in database")
+
+ @property
+ def sorted_migrations(self):
+ """
+ Sort migrations if necessary and store in self._sorted_migrations
+ """
+ if not self._sorted_migrations:
+ self._sorted_migrations = sorted(
+ self.migration_registry.items(),
+ # sort on the key... the migration number
+ key=lambda migration_tuple: migration_tuple[0])
+
+ return self._sorted_migrations
+
+ def latest_migration(self):
+ """
+ Return a migration number for the latest migration, or 0 if
+ there are no migrations.
+ """
+ if self.sorted_migrations:
+ return self.sorted_migrations[-1][0]
+ else:
+ # If no migrations have been set, we start at 0.
+ return 0
+
+ def set_current_migration(self, migration_number):
+ """
+ Set the migration in the database to migration_number
+ """
+ # Add the mediagoblin migration if necessary
+ self.database[u'app_metadata'].update(
+ {u'_id': u'mediagoblin'},
+ {u'$set': {u'current_migration': migration_number}},
+ upsert=True)
+
+ def install_migration_version_if_missing(self):
+ """
+ Sets the migration to the latest version if no migration
+ version at all is set.
+ """
+ mgoblin_metadata = self.database[u'app_metadata'].find_one(
+ {u'_id': u'mediagoblin'})
+ if not mgoblin_metadata:
+ latest_migration = self.latest_migration()
+ self.set_current_migration(latest_migration)
+
+ def database_current_migration(self):
+ """
+ Return the current migration in the database.
+ """
+ mgoblin_metadata = self.database[u'app_metadata'].find_one(
+ {u'_id': u'mediagoblin'})
+ if not mgoblin_metadata:
+ return None
+ else:
+ return mgoblin_metadata[u'current_migration']
+
+ def database_at_latest_migration(self):
+ """
+ See if the database is at the latest migration.
+ Returns a boolean.
+ """
+ current_migration = self.database_current_migration()
+ return current_migration == self.latest_migration()
+
+ def migrations_to_run(self):
+ """
+ Get a list of migrations to run still, if any.
+
+ Note that calling this will set your migration version to the
+ latest version if it isn't installed to anything yet!
+ """
+ self._ensure_current_migration_record()
+
+ db_current_migration = self.database_current_migration()
+
+ return [
+ (migration_number, migration_func)
+ for migration_number, migration_func in self.sorted_migrations
+ if migration_number > db_current_migration]
+
+ def migrate_new(self, pre_callback=None, post_callback=None):
+ """
+ Run all migrations.
+
+ Includes two optional args:
+ - pre_callback: if called, this is a callback on something to
+ run pre-migration. Takes (migration_number, migration_func)
+ as arguments
+ - pre_callback: if called, this is a callback on something to
+ run post-migration. Takes (migration_number, migration_func)
+ as arguments
+ """
+ # If we aren't set to any version number, presume we're at the
+ # latest (which means we'll do nothing here...)
+ self.install_migration_version_if_missing()
+
+ for migration_number, migration_func in self.migrations_to_run():
+ if pre_callback:
+ pre_callback(migration_number, migration_func)
+ migration_func(self.database)
+ self.set_current_migration(migration_number)
+ if post_callback:
+ post_callback(migration_number, migration_func)
diff --git a/mediagoblin/gmg_commands/migrate.py b/mediagoblin/gmg_commands/migrate.py
index ab1a267b..e8d43a1f 100644
--- a/mediagoblin/gmg_commands/migrate.py
+++ b/mediagoblin/gmg_commands/migrate.py
@@ -14,10 +14,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import sys
from mediagoblin.db import migrations
from mediagoblin.db import util as db_util
-from mediagoblin.gmg_commands import util as commands_util
+from mediagoblin.db.open import setup_connection_and_db_from_config
+from mediagoblin.init.config import read_mediagoblin_config
def migrate_parser_setup(subparser):
@@ -26,31 +28,41 @@ def migrate_parser_setup(subparser):
help="Config file used to set up environment")
+def _print_started_migration(migration_number, migration_func):
+ sys.stdout.write(
+ "Running migration %s, '%s'... " % (
+ migration_number, migration_func.func_name))
+ sys.stdout.flush()
+
+
+def _print_finished_migration(migration_number, migration_func):
+ sys.stdout.write("done.\n")
+ sys.stdout.flush()
+
+
def migrate(args):
- mgoblin_app = commands_util.setup_app(args)
+ config, validation_result = read_mediagoblin_config(args.conf_file)
+ connection, db = setup_connection_and_db_from_config(
+ config['mediagoblin'], use_pymongo=True)
+ migration_manager = db_util.MigrationManager(db)
# Clear old indexes
print "== Clearing old indexes... =="
- removed_indexes = db_util.remove_deprecated_indexes(mgoblin_app.db)
+ removed_indexes = db_util.remove_deprecated_indexes(db)
for collection, index_name in removed_indexes:
print "Removed index '%s' in collection '%s'" % (
index_name, collection)
# Migrate
- print "== Applying migrations... =="
- for model_name in migrations.MIGRATE_CLASSES:
- model = getattr(mgoblin_app.db, model_name)
-
- if not hasattr(model, 'migration_handler') or not model.collection:
- continue
-
- migration = model.migration_handler(model)
- migration.migrate_all(collection=model.collection)
+ print "\n== Applying migrations... =="
+ migration_manager.migrate_new(
+ pre_callback=_print_started_migration,
+ post_callback=_print_finished_migration)
# Add new indexes
- print "== Adding new indexes... =="
- new_indexes = db_util.add_new_indexes(mgoblin_app.db)
+ print "\n== Adding new indexes... =="
+ new_indexes = db_util.add_new_indexes(db)
for collection, index_name in new_indexes:
print "Added index '%s' to collection '%s'" % (
diff --git a/mediagoblin/tests/test_migrations.py b/mediagoblin/tests/test_migrations.py
new file mode 100644
index 00000000..127b90e1
--- /dev/null
+++ b/mediagoblin/tests/test_migrations.py
@@ -0,0 +1,402 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011 Free Software Foundation, Inc
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+from nose.tools import assert_raises
+from pymongo import Connection
+
+from mediagoblin.tests.tools import (
+ install_fixtures_simple, assert_db_meets_expected)
+from mediagoblin.db.util import (
+ RegisterMigration, MigrationManager, ObjectId,
+ MissingCurrentMigration)
+
+# This one will get filled with local migrations
+TEST_MIGRATION_REGISTRY = {}
+# this one won't get filled
+TEST_EMPTY_MIGRATION_REGISTRY = {}
+
+MIGRATION_DB_NAME = u'__mediagoblin_test_migrations__'
+
+
+######################
+# Fake test migrations
+######################
+
+@RegisterMigration(1, TEST_MIGRATION_REGISTRY)
+def creature_add_magical_powers(database):
+ """
+ Add lists of magical powers.
+
+ This defaults to [], an empty list. Since we haven't declared any
+ magical powers, all existing monsters should
+ """
+ database['creatures'].update(
+ {'magical_powers': {'$exists': False}},
+ {'$set': {'magical_powers': []}},
+ multi=True)
+
+
+@RegisterMigration(2, TEST_MIGRATION_REGISTRY)
+def creature_rename_num_legs_to_num_limbs(database):
+ """
+ It turns out we want to track how many limbs a creature has, not
+ just how many legs. We don't care about the ambiguous distinction
+ between arms/legs currently.
+ """
+ # $rename not available till 1.7.2+, Debian Stable only includes
+ # 1.4.4... we should do renames manually for now :(
+
+ collection = database['creatures']
+ target = collection.find(
+ {'num_legs': {'$exists': True}})
+
+ for document in target:
+ # A lame manual renaming.
+ document['num_limbs'] = document.pop('num_legs')
+ collection.save(document)
+
+
+@RegisterMigration(3, TEST_MIGRATION_REGISTRY)
+def creature_remove_is_demon(database):
+ """
+ It turns out we don't care much about whether creatures are demons
+ or not.
+ """
+ database['creatures'].update(
+ {'is_demon': {'$exists': True}},
+ {'$unset': {'is_demon': 1}},
+ multi=True)
+
+
+@RegisterMigration(4, TEST_MIGRATION_REGISTRY)
+def level_exits_dict_to_list(database):
+ """
+ For the sake of the indexes we want to write, and because we
+ intend to add more flexible fields, we want to move level exits
+ from like:
+
+ {'big_door': 'castle_level_id',
+ 'trapdoor': 'dungeon_level_id'}
+
+ to like:
+
+ [{'name': 'big_door',
+ 'exits_to': 'castle_level_id'},
+ {'name': 'trapdoor',
+ 'exits_to': 'dungeon_level_id'}]
+ """
+ collection = database['levels']
+ target = collection.find(
+ {'exits': {'$type': 3}})
+
+ for level in target:
+ new_exits = []
+ for exit_name, exits_to in level['exits'].items():
+ new_exits.append(
+ {'name': exit_name,
+ 'exits_to': exits_to})
+
+ level['exits'] = new_exits
+ collection.save(level)
+
+
+CENTIPEDE_OBJECTID = ObjectId()
+WOLF_OBJECTID = ObjectId()
+WIZARDSNAKE_OBJECTID = ObjectId()
+
+UNMIGRATED_DBDATA = {
+ 'creatures': [
+ {'_id': CENTIPEDE_OBJECTID,
+ 'name': 'centipede',
+ 'num_legs': 100,
+ 'is_demon': False},
+ {'_id': WOLF_OBJECTID,
+ 'name': 'wolf',
+ 'num_legs': 4,
+ 'is_demon': False},
+ # don't ask me what a wizardsnake is.
+ {'_id': WIZARDSNAKE_OBJECTID,
+ 'name': 'wizardsnake',
+ 'num_legs': 0,
+ 'is_demon': True}],
+ 'levels': [
+ {'_id': 'necroplex',
+ 'name': 'The Necroplex',
+ 'description': 'A complex full of pure deathzone.',
+ 'exits': {
+ 'deathwell': 'evilstorm',
+ 'portal': 'central_park'}},
+ {'_id': 'evilstorm',
+ 'name': 'Evil Storm',
+ 'description': 'A storm full of pure evil.',
+ 'exits': {}}, # you can't escape the evilstorm
+ {'_id': 'central_park',
+ 'name': 'Central Park, NY, NY',
+ 'description': "New York's friendly Central Park.",
+ 'exits': {
+ 'portal': 'necroplex'}}]}
+
+
+EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA = {
+ 'creatures': [
+ {'_id': CENTIPEDE_OBJECTID,
+ 'name': 'centipede',
+ 'num_limbs': 100,
+ 'magical_powers': []},
+ {'_id': WOLF_OBJECTID,
+ 'name': 'wolf',
+ 'num_limbs': 4,
+ # kept around namely to check that it *isn't* removed!
+ 'magical_powers': []},
+ {'_id': WIZARDSNAKE_OBJECTID,
+ 'name': 'wizardsnake',
+ 'num_limbs': 0,
+ 'magical_powers': []}],
+ 'levels': [
+ {'_id': 'necroplex',
+ 'name': 'The Necroplex',
+ 'description': 'A complex full of pure deathzone.',
+ 'exits': [
+ {'name': 'deathwell',
+ 'exits_to': 'evilstorm'},
+ {'name': 'portal',
+ 'exits_to': 'central_park'}]},
+ {'_id': 'evilstorm',
+ 'name': 'Evil Storm',
+ 'description': 'A storm full of pure evil.',
+ 'exits': []}, # you can't escape the evilstorm
+ {'_id': 'central_park',
+ 'name': 'Central Park, NY, NY',
+ 'description': "New York's friendly Central Park.",
+ 'exits': [
+ {'name': 'portal',
+ 'exits_to': 'necroplex'}]}]}
+
+# We want to make sure that if we're at migration 3, migration 3
+# doesn't get re-run.
+
+SEMI_MIGRATED_DBDATA = {
+ 'creatures': [
+ {'_id': CENTIPEDE_OBJECTID,
+ 'name': 'centipede',
+ 'num_limbs': 100,
+ 'magical_powers': []},
+ {'_id': WOLF_OBJECTID,
+ 'name': 'wolf',
+ 'num_limbs': 4,
+ # kept around namely to check that it *isn't* removed!
+ 'is_demon': False,
+ 'magical_powers': [
+ 'ice_breath', 'death_stare']},
+ {'_id': WIZARDSNAKE_OBJECTID,
+ 'name': 'wizardsnake',
+ 'num_limbs': 0,
+ 'magical_powers': [
+ 'death_rattle', 'sneaky_stare',
+ 'slithery_smoke', 'treacherous_tremors'],
+ 'is_demon': True}],
+ 'levels': [
+ {'_id': 'necroplex',
+ 'name': 'The Necroplex',
+ 'description': 'A complex full of pure deathzone.',
+ 'exits': {
+ 'deathwell': 'evilstorm',
+ 'portal': 'central_park'}},
+ {'_id': 'evilstorm',
+ 'name': 'Evil Storm',
+ 'description': 'A storm full of pure evil.',
+ 'exits': {}}, # you can't escape the evilstorm
+ {'_id': 'central_park',
+ 'name': 'Central Park, NY, NY',
+ 'description': "New York's friendly Central Park.",
+ 'exits': {
+ 'portal': 'necroplex'}}]}
+
+
+EXPECTED_POST_MIGRATION_SEMI_MIGRATED_DBDATA = {
+ 'creatures': [
+ {'_id': CENTIPEDE_OBJECTID,
+ 'name': 'centipede',
+ 'num_limbs': 100,
+ 'magical_powers': []},
+ {'_id': WOLF_OBJECTID,
+ 'name': 'wolf',
+ 'num_limbs': 4,
+ # kept around namely to check that it *isn't* removed!
+ 'is_demon': False,
+ 'magical_powers': [
+ 'ice_breath', 'death_stare']},
+ {'_id': WIZARDSNAKE_OBJECTID,
+ 'name': 'wizardsnake',
+ 'num_limbs': 0,
+ 'magical_powers': [
+ 'death_rattle', 'sneaky_stare',
+ 'slithery_smoke', 'treacherous_tremors'],
+ 'is_demon': True}],
+ 'levels': [
+ {'_id': 'necroplex',
+ 'name': 'The Necroplex',
+ 'description': 'A complex full of pure deathzone.',
+ 'exits': [
+ {'name': 'deathwell',
+ 'exits_to': 'evilstorm'},
+ {'name': 'portal',
+ 'exits_to': 'central_park'}]},
+ {'_id': 'evilstorm',
+ 'name': 'Evil Storm',
+ 'description': 'A storm full of pure evil.',
+ 'exits': []}, # you can't escape the evilstorm
+ {'_id': 'central_park',
+ 'name': 'Central Park, NY, NY',
+ 'description': "New York's friendly Central Park.",
+ 'exits': [
+ {'name': 'portal',
+ 'exits_to': 'necroplex'}]}]}
+
+
+class TestMigrations(object):
+ def setUp(self):
+ # Set up the connection, drop an existing possible database
+ self.connection = Connection()
+ self.connection.drop_database(MIGRATION_DB_NAME)
+ self.db = Connection()[MIGRATION_DB_NAME]
+ self.migration_manager = MigrationManager(
+ self.db, TEST_MIGRATION_REGISTRY)
+ self.empty_migration_manager = MigrationManager(
+ self.db, TEST_EMPTY_MIGRATION_REGISTRY)
+ self.run_migrations = []
+
+ def tearDown(self):
+ self.connection.drop_database(MIGRATION_DB_NAME)
+
+ def _record_migration(self, migration_number, migration_func):
+ self.run_migrations.append((migration_number, migration_func))
+
+ def test_migrations_registered_and_sorted(self):
+ """
+ Make sure that migrations get registered and are sorted right
+ in the migration manager
+ """
+ assert TEST_MIGRATION_REGISTRY == {
+ 1: creature_add_magical_powers,
+ 2: creature_rename_num_legs_to_num_limbs,
+ 3: creature_remove_is_demon,
+ 4: level_exits_dict_to_list}
+ assert self.migration_manager.sorted_migrations == [
+ (1, creature_add_magical_powers),
+ (2, creature_rename_num_legs_to_num_limbs),
+ (3, creature_remove_is_demon),
+ (4, level_exits_dict_to_list)]
+ assert self.empty_migration_manager.sorted_migrations == []
+
+ def test_run_full_migrations(self):
+ """
+ Make sure that running the full migration suite from 0 updates
+ everything
+ """
+ self.migration_manager.set_current_migration(0)
+ assert self.migration_manager.database_current_migration() == 0
+ install_fixtures_simple(self.db, UNMIGRATED_DBDATA)
+ self.migration_manager.migrate_new(post_callback=self._record_migration)
+
+ assert self.run_migrations == [
+ (1, creature_add_magical_powers),
+ (2, creature_rename_num_legs_to_num_limbs),
+ (3, creature_remove_is_demon),
+ (4, level_exits_dict_to_list)]
+
+ assert_db_meets_expected(
+ self.db, EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA)
+
+ # Make sure the migration is recorded correctly
+ assert self.migration_manager.database_current_migration() == 4
+
+ # run twice! It should do nothing the second time.
+ # ------------------------------------------------
+ self.run_migrations = []
+ self.migration_manager.migrate_new(post_callback=self._record_migration)
+ assert self.run_migrations == []
+ assert_db_meets_expected(
+ self.db, EXPECTED_POST_MIGRATION_UNMIGRATED_DBDATA)
+ assert self.migration_manager.database_current_migration() == 4
+
+
+ def test_run_partial_migrations(self):
+ """
+ Make sure that running full migration suite from 3 only runs
+ last migration
+ """
+ self.migration_manager.set_current_migration(3)
+ assert self.migration_manager.database_current_migration() == 3
+ install_fixtures_simple(self.db, SEMI_MIGRATED_DBDATA)
+ self.migration_manager.migrate_new(post_callback=self._record_migration)
+
+ assert self.run_migrations == [
+ (4, level_exits_dict_to_list)]
+
+ assert_db_meets_expected(
+ self.db, EXPECTED_POST_MIGRATION_SEMI_MIGRATED_DBDATA)
+
+ # Make sure the migration is recorded correctly
+ assert self.migration_manager.database_current_migration() == 4
+
+ def test_migrations_recorded_as_latest(self):
+ """
+ Make sure that if we don't have a migration_status
+ pre-recorded it's marked as the latest
+ """
+ self.migration_manager.install_migration_version_if_missing()
+ assert self.migration_manager.database_current_migration() == 4
+
+ def test_no_migrations_recorded_as_zero(self):
+ """
+ Make sure that if we don't have a migration_status
+ but there *are* no migrations that it's marked as 0
+ """
+ self.empty_migration_manager.install_migration_version_if_missing()
+ assert self.empty_migration_manager.database_current_migration() == 0
+
+ def test_migrations_to_run(self):
+ """
+ Make sure we get the right list of migrations to run
+ """
+ self.migration_manager.set_current_migration(0)
+
+ assert self.migration_manager.migrations_to_run() == [
+ (1, creature_add_magical_powers),
+ (2, creature_rename_num_legs_to_num_limbs),
+ (3, creature_remove_is_demon),
+ (4, level_exits_dict_to_list)]
+
+ self.migration_manager.set_current_migration(3)
+
+ assert self.migration_manager.migrations_to_run() == [
+ (4, level_exits_dict_to_list)]
+
+ self.migration_manager.set_current_migration(4)
+
+ assert self.migration_manager.migrations_to_run() == []
+
+
+ def test_no_migrations_raises_exception(self):
+ """
+ If we don't have the current migration set in the database,
+ this should error out.
+ """
+ assert_raises(
+ MissingCurrentMigration,
+ self.migration_manager.migrations_to_run)
diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py
index e56af4de..4b61f259 100644
--- a/mediagoblin/tests/tools.py
+++ b/mediagoblin/tests/tools.py
@@ -118,3 +118,35 @@ def setup_fresh_app(func):
return func(test_app, *args, **kwargs)
return _make_safe(wrapper, func)
+
+
+def install_fixtures_simple(db, fixtures):
+ """
+ Very simply install fixtures in the database
+ """
+ for collection_name, collection_fixtures in fixtures.iteritems():
+ collection = db[collection_name]
+ for fixture in collection_fixtures:
+ collection.insert(fixture)
+
+
+def assert_db_meets_expected(db, expected):
+ """
+ Assert a database contains the things we expect it to.
+
+ Objects are found via '_id', so you should make sure your document
+ has an _id.
+
+ Args:
+ - db: pymongo or mongokit database connection
+ - expected: the data we expect. Formatted like:
+ {'collection_name': [
+ {'_id': 'foo',
+ 'some_field': 'some_value'},]}
+ """
+ for collection_name, collection_data in expected.iteritems():
+ collection = db[collection_name]
+ for expected_document in collection_data:
+ document = collection.find_one({'_id': expected_document['_id']})
+ assert document is not None # make sure it exists
+ assert document == expected_document # make sure it matches