migration_tools.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. # GNU MediaGoblin -- federated, autonomous media hosting
  2. # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. from __future__ import unicode_literals
  17. import logging
  18. import os
  19. from alembic import command
  20. from alembic.config import Config
  21. from alembic.migration import MigrationContext
  22. from mediagoblin.db.base import Base
  23. from mediagoblin.tools.common import simple_printer
  24. from sqlalchemy import Table
  25. from sqlalchemy.sql import select
  26. log = logging.getLogger(__name__)
  27. class TableAlreadyExists(Exception):
  28. pass
  29. class AlembicMigrationManager(object):
  30. def __init__(self, session):
  31. root_dir = os.path.abspath(os.path.dirname(os.path.dirname(
  32. os.path.dirname(__file__))))
  33. alembic_cfg_path = os.path.join(root_dir, 'alembic.ini')
  34. self.alembic_cfg = Config(alembic_cfg_path)
  35. self.session = session
  36. def get_current_revision(self):
  37. context = MigrationContext.configure(self.session.bind)
  38. return context.get_current_revision()
  39. def upgrade(self, version):
  40. return command.upgrade(self.alembic_cfg, version or 'head')
  41. def downgrade(self, version):
  42. if isinstance(version, int) or version is None or version.isdigit():
  43. version = 'base'
  44. return command.downgrade(self.alembic_cfg, version)
  45. def stamp(self, revision):
  46. return command.stamp(self.alembic_cfg, revision=revision)
  47. def init_tables(self):
  48. Base.metadata.create_all(self.session.bind)
  49. # load the Alembic configuration and generate the
  50. # version table, "stamping" it with the most recent rev:
  51. # XXX: we need to find a better way to detect current installations
  52. # using sqlalchemy-migrate because we don't have to create all table
  53. # for them
  54. command.stamp(self.alembic_cfg, 'head')
  55. def init_or_migrate(self, version=None):
  56. # XXX: we need to call this method when we ditch
  57. # sqlalchemy-migrate entirely
  58. # if self.get_current_revision() is None:
  59. # self.init_tables()
  60. self.upgrade(version)
  61. class MigrationManager(object):
  62. """
  63. Migration handling tool.
  64. Takes information about a database, lets you update the database
  65. to the latest migrations, etc.
  66. """
  67. def __init__(self, name, models, foundations, migration_registry, session,
  68. printer=simple_printer):
  69. """
  70. Args:
  71. - name: identifier of this section of the database
  72. - session: session we're going to migrate
  73. - migration_registry: where we should find all migrations to
  74. run
  75. """
  76. self.name = name
  77. self.models = models
  78. self.foundations = foundations
  79. self.session = session
  80. self.migration_registry = migration_registry
  81. self._sorted_migrations = None
  82. self.printer = printer
  83. # For convenience
  84. from mediagoblin.db.models import MigrationData
  85. self.migration_model = MigrationData
  86. self.migration_table = MigrationData.__table__
  87. @property
  88. def sorted_migrations(self):
  89. """
  90. Sort migrations if necessary and store in self._sorted_migrations
  91. """
  92. if not self._sorted_migrations:
  93. self._sorted_migrations = sorted(
  94. self.migration_registry.items(),
  95. # sort on the key... the migration number
  96. key=lambda migration_tuple: migration_tuple[0])
  97. return self._sorted_migrations
  98. @property
  99. def migration_data(self):
  100. """
  101. Get the migration row associated with this object, if any.
  102. """
  103. return self.session.query(
  104. self.migration_model).filter_by(name=self.name).first()
  105. @property
  106. def latest_migration(self):
  107. """
  108. Return a migration number for the latest migration, or 0 if
  109. there are no migrations.
  110. """
  111. if self.sorted_migrations:
  112. return self.sorted_migrations[-1][0]
  113. else:
  114. # If no migrations have been set, we start at 0.
  115. return 0
  116. @property
  117. def database_current_migration(self):
  118. """
  119. Return the current migration in the database.
  120. """
  121. # If the table doesn't even exist, return None.
  122. if not self.migration_table.exists(self.session.bind):
  123. return None
  124. # Also return None if self.migration_data is None.
  125. if self.migration_data is None:
  126. return None
  127. return self.migration_data.version
  128. def set_current_migration(self, migration_number=None):
  129. """
  130. Set the migration in the database to migration_number
  131. (or, the latest available)
  132. """
  133. self.migration_data.version = migration_number or self.latest_migration
  134. self.session.commit()
  135. def migrations_to_run(self):
  136. """
  137. Get a list of migrations to run still, if any.
  138. Note that this will fail if there's no migration record for
  139. this class!
  140. """
  141. assert self.database_current_migration is not None
  142. db_current_migration = self.database_current_migration
  143. return [
  144. (migration_number, migration_func)
  145. for migration_number, migration_func in self.sorted_migrations
  146. if migration_number > db_current_migration]
  147. def init_tables(self):
  148. """
  149. Create all tables relative to this package
  150. """
  151. # sanity check before we proceed, none of these should be created
  152. for model in self.models:
  153. # Maybe in the future just print out a "Yikes!" or something?
  154. if model.__table__.exists(self.session.bind):
  155. raise TableAlreadyExists(
  156. u"Intended to create table '%s' but it already exists" %
  157. model.__table__.name)
  158. self.migration_model.metadata.create_all(
  159. self.session.bind,
  160. tables=[model.__table__ for model in self.models])
  161. def populate_table_foundations(self):
  162. """
  163. Create the table foundations (default rows) as layed out in FOUNDATIONS
  164. in mediagoblin.db.models
  165. """
  166. for Model, rows in self.foundations.items():
  167. self.printer(u' + Laying foundations for %s table\n' %
  168. (Model.__name__))
  169. for parameters in rows:
  170. new_row = Model(**parameters)
  171. self.session.add(new_row)
  172. def create_new_migration_record(self):
  173. """
  174. Create a new migration record for this migration set
  175. """
  176. migration_record = self.migration_model(
  177. name=self.name,
  178. version=self.latest_migration)
  179. self.session.add(migration_record)
  180. self.session.commit()
  181. def dry_run(self):
  182. """
  183. Print out a dry run of what we would have upgraded.
  184. """
  185. if self.database_current_migration is None:
  186. self.printer(
  187. u'~> Woulda initialized: %s\n' % self.name_for_printing())
  188. return u'inited'
  189. migrations_to_run = self.migrations_to_run()
  190. if migrations_to_run:
  191. self.printer(
  192. u'~> Woulda updated %s:\n' % self.name_for_printing())
  193. for migration_number, migration_func in migrations_to_run():
  194. self.printer(
  195. u' + Would update %s, "%s"\n' % (
  196. migration_number, migration_func.func_name))
  197. return u'migrated'
  198. def name_for_printing(self):
  199. if self.name == u'__main__':
  200. return u"main mediagoblin tables"
  201. else:
  202. return u'plugin "%s"' % self.name
  203. def init_or_migrate(self):
  204. """
  205. Initialize the database or migrate if appropriate.
  206. Returns information about whether or not we initialized
  207. ('inited'), migrated ('migrated'), or did nothing (None)
  208. """
  209. assure_migrations_table_setup(self.session)
  210. # Find out what migration number, if any, this database data is at,
  211. # and what the latest is.
  212. migration_number = self.database_current_migration
  213. # Is this our first time? Is there even a table entry for
  214. # this identifier?
  215. # If so:
  216. # - create all tables
  217. # - create record in migrations registry
  218. # - print / inform the user
  219. # - return 'inited'
  220. if migration_number is None:
  221. self.printer(u"-> Initializing %s... " % self.name_for_printing())
  222. self.init_tables()
  223. # auto-set at latest migration number
  224. self.create_new_migration_record()
  225. self.printer(u"done.\n")
  226. self.populate_table_foundations()
  227. self.set_current_migration()
  228. return u'inited'
  229. # Run migrations, if appropriate.
  230. migrations_to_run = self.migrations_to_run()
  231. if migrations_to_run:
  232. self.printer(
  233. u'-> Updating %s:\n' % self.name_for_printing())
  234. for migration_number, migration_func in migrations_to_run:
  235. self.printer(
  236. u' + Running migration %s, "%s"... ' % (
  237. migration_number, migration_func.__name__))
  238. migration_func(self.session)
  239. self.set_current_migration(migration_number)
  240. self.printer('done.\n')
  241. return u'migrated'
  242. # Otherwise return None. Well it would do this anyway, but
  243. # for clarity... ;)
  244. return None
  245. class RegisterMigration(object):
  246. """
  247. Tool for registering migrations
  248. Call like:
  249. @RegisterMigration(33)
  250. def update_dwarves(database):
  251. [...]
  252. This will register your migration with the default migration
  253. registry. Alternately, to specify a very specific
  254. migration_registry, you can pass in that as the second argument.
  255. Note, the number of your migration should NEVER be 0 or less than
  256. 0. 0 is the default "no migrations" state!
  257. """
  258. def __init__(self, migration_number, migration_registry):
  259. assert migration_number > 0, "Migration number must be > 0!"
  260. assert migration_number not in migration_registry, \
  261. "Duplicate migration numbers detected! That's not allowed!"
  262. self.migration_number = migration_number
  263. self.migration_registry = migration_registry
  264. def __call__(self, migration):
  265. self.migration_registry[self.migration_number] = migration
  266. return migration
  267. def assure_migrations_table_setup(db):
  268. """
  269. Make sure the migrations table is set up in the database.
  270. """
  271. from mediagoblin.db.models import MigrationData
  272. if not MigrationData.__table__.exists(db.bind):
  273. MigrationData.metadata.create_all(
  274. db.bind, tables=[MigrationData.__table__])
  275. def inspect_table(metadata, table_name):
  276. """Simple helper to get a ref to an already existing table"""
  277. return Table(table_name, metadata, autoload=True,
  278. autoload_with=metadata.bind)
  279. def replace_table_hack(db, old_table, replacement_table):
  280. """
  281. A function to fully replace a current table with a new one for migrati-
  282. -ons. This is necessary because some changes are made tricky in some situa-
  283. -tion, for example, dropping a boolean column in sqlite is impossible w/o
  284. this method
  285. :param old_table A ref to the old table, gotten through
  286. inspect_table
  287. :param replacement_table A ref to the new table, gotten through
  288. inspect_table
  289. Users are encouraged to sqlalchemy-migrate replace table solutions, unless
  290. that is not possible... in which case, this solution works,
  291. at least for sqlite.
  292. """
  293. surviving_columns = replacement_table.columns.keys()
  294. old_table_name = old_table.name
  295. for row in db.execute(select(
  296. [column for column in old_table.columns
  297. if column.name in surviving_columns])):
  298. db.execute(replacement_table.insert().values(**row))
  299. db.commit()
  300. old_table.drop()
  301. db.commit()
  302. replacement_table.rename(old_table_name)
  303. db.commit()