summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2014-11-20 18:08:02 -0500
committerMike Bayer <mike_mp@zzzcomputing.com>2014-11-20 18:08:02 -0500
commit5c747a068b539cec9dd88836427f57352f3f0735 (patch)
tree46fe04c30ee7f7f72d45d5d987868889718095ff
parent0b63884a2ce2b4b89f2ddf0db7fba47fce5540f0 (diff)
downloadalembic-5c747a068b539cec9dd88836427f57352f3f0735.tar.gz
- The "multiple heads / branches" feature has now landed. This is
by far the most significant change Alembic has seen since its inception; while the workflow of most commands hasn't changed, and the format of version files and the ``alembic_version`` table are unchanged as well, a new suite of features opens up in the case where multiple version files refer to the same parent, or to the "base". Merging of branches, operating across distinct named heads, and multiple independent bases are now all supported. The feature incurs radical changes to the internals of versioning and traversal, and should be treated as "beta mode" for the next several subsequent releases within 0.7. fixes #167
-rw-r--r--alembic/command.py155
-rw-r--r--alembic/compat.py26
-rw-r--r--alembic/config.py141
-rw-r--r--alembic/ddl/impl.py2
-rw-r--r--alembic/environment.py44
-rw-r--r--alembic/migration.py481
-rw-r--r--alembic/operations.py60
-rw-r--r--alembic/revision.py646
-rw-r--r--alembic/script.py593
-rw-r--r--alembic/templates/generic/script.py.mako3
-rw-r--r--alembic/templates/multidb/script.py.mako3
-rw-r--r--alembic/templates/pylons/script.py.mako3
-rw-r--r--alembic/testing/env.py7
-rw-r--r--alembic/util.py30
-rw-r--r--docs/build/api.rst31
-rw-r--r--docs/build/changelog.rst42
-rw-r--r--docs/build/tutorial.rst729
-rw-r--r--tests/test_command.py247
-rw-r--r--tests/test_offline_environment.py18
-rw-r--r--tests/test_revision.py717
-rw-r--r--tests/test_script_consumption.py6
-rw-r--r--tests/test_script_production.py81
-rw-r--r--tests/test_version_table.py183
-rw-r--r--tests/test_version_traversal.py450
24 files changed, 4044 insertions, 654 deletions
diff --git a/alembic/command.py b/alembic/command.py
index 1d0d147..8b9f4d7 100644
--- a/alembic/command.py
+++ b/alembic/command.py
@@ -18,7 +18,7 @@ def list_templates(config):
config.print_stdout("%s - %s", tempname, synopsis)
config.print_stdout("\nTemplates are used via the 'init' command, e.g.:")
- config.print_stdout("\n alembic init --template pylons ./scripts")
+ config.print_stdout("\n alembic init --template generic ./scripts")
def init(config, directory, template='generic'):
@@ -64,7 +64,9 @@ def init(config, directory, template='generic'):
"settings in %r before proceeding." % config_file)
-def revision(config, message=None, autogenerate=False, sql=False):
+def revision(
+ config, message=None, autogenerate=False, sql=False,
+ head="head", splice=False, branch_label=None):
"""Create a new revision file."""
script = ScriptDirectory.from_config(config)
@@ -82,7 +84,8 @@ def revision(config, message=None, autogenerate=False, sql=False):
environment = True
def retrieve_migrations(rev, context):
- if script.get_revision(rev) is not script.get_revision("head"):
+ if set(script.get_revisions(rev)) != \
+ set(script.get_revisions("heads")):
raise util.CommandError("Target database is not up to date.")
autogen._produce_migration_diffs(context, template_args, imports)
return []
@@ -99,8 +102,32 @@ def revision(config, message=None, autogenerate=False, sql=False):
template_args=template_args,
):
script.run_env()
- return script.generate_revision(util.rev_id(), message, refresh=True,
- **template_args)
+ return script.generate_revision(
+ util.rev_id(), message, refresh=True,
+ head=head, splice=splice, branch_labels=branch_label,
+ **template_args)
+
+
+def merge(config, revisions, message=None, branch_label=None):
+ """Merge two revisions together. Creates a new migration file.
+
+ .. versionadded:: 0.7.0
+
+ .. seealso::
+
+ :ref:`branches`
+
+ """
+
+ script = ScriptDirectory.from_config(config)
+ template_args = {
+ 'config': config # Let templates use config for
+ # e.g. multiple databases
+ }
+ return script.generate_revision(
+ util.rev_id(), message, refresh=True,
+ head=revisions, branch_labels=branch_label,
+ **template_args)
def upgrade(config, revision, sql=False, tag=None):
@@ -157,7 +184,28 @@ def downgrade(config, revision, sql=False, tag=None):
script.run_env()
-def history(config, rev_range=None):
+def show(config, rev):
+ """Show the revision(s) denoted by the given symbol."""
+
+ script = ScriptDirectory.from_config(config)
+
+ if rev == "current":
+ def show_current(rev, context):
+ for sc in script.get_revisions(rev):
+ config.print_stdout(sc.log_entry)
+ return []
+ with EnvironmentContext(
+ config,
+ script,
+ fn=show_current
+ ):
+ script.run_env()
+ else:
+ for sc in script.get_revisions(rev):
+ config.print_stdout(sc.log_entry)
+
+
+def history(config, rev_range=None, verbose=False):
"""List changeset scripts in chronological order."""
script = ScriptDirectory.from_config(config)
@@ -173,10 +221,11 @@ def history(config, rev_range=None):
def _display_history(config, script, base, head):
for sc in script.walk_revisions(
base=base or "base",
- head=head or "head"):
- if sc.is_head:
- config.print_stdout("")
- config.print_stdout(sc.log_entry)
+ head=head or "heads"):
+ config.print_stdout(
+ sc.cmd_format(
+ verbose=verbose, include_branches=True,
+ include_doc=True, include_parents=True))
def _display_history_w_current(config, script, base=None, head=None):
def _display_current_history(rev, context):
@@ -201,37 +250,51 @@ def history(config, rev_range=None):
_display_history(config, script, base, head)
-def branches(config):
- """Show current un-spliced branch points"""
+def heads(config, verbose=False):
+ """Show current available heads in the script directory"""
+
+ script = ScriptDirectory.from_config(config)
+ for rev in script.get_revisions("heads"):
+ config.print_stdout(
+ rev.cmd_format(
+ verbose, include_branches=True, tree_indicators=False))
+
+
+def branches(config, verbose=False):
+ """Show current branch points"""
script = ScriptDirectory.from_config(config)
for sc in script.walk_revisions():
if sc.is_branch_point:
- config.print_stdout(sc)
- for rev in sc.nextrev:
- config.print_stdout("%s -> %s",
- " " * len(str(sc.down_revision)),
- script.get_revision(rev)
- )
+ config.print_stdout(
+ "%s\n%s\n",
+ sc.cmd_format(verbose, include_branches=True),
+ "\n".join(
+ "%s -> %s" % (
+ " " * len(str(sc.revision)),
+ rev_obj.cmd_format(
+ False, include_branches=True, include_doc=verbose)
+ ) for rev_obj in
+ (script.get_revision(rev) for rev in sc.nextrev)
+ )
+ )
-def current(config, head_only=False):
- """Display the current revision for each database."""
+def current(config, verbose=False, head_only=False):
+ """Display the current revision for a database."""
script = ScriptDirectory.from_config(config)
+ if head_only:
+ util.warn("--head-only is deprecated")
+
def display_version(rev, context):
- rev = script.get_revision(rev)
-
- if head_only:
- config.print_stdout("%s%s" % (
- rev.revision if rev else None,
- " (head)" if rev and rev.is_head else ""))
-
- else:
- config.print_stdout("Current revision for %s: %s",
- util.obfuscate_url_pw(
- context.connection.engine.url),
- rev)
+ if verbose:
+ config.print_stdout(
+ "Current revision(s) for %s:",
+ util.obfuscate_url_pw(context.connection.engine.url)
+ )
+ for rev in script.get_revisions(rev):
+ config.print_stdout(rev.cmd_format(verbose))
return []
with EnvironmentContext(
@@ -248,31 +311,27 @@ def stamp(config, revision, sql=False, tag=None):
script = ScriptDirectory.from_config(config)
+ starting_rev = None
+ if ":" in revision:
+ if not sql:
+ raise util.CommandError("Range revision not allowed")
+ starting_rev, revision = revision.split(':', 2)
+ starting_rev = script.get_revision(starting_rev)
+ if starting_rev is not None:
+ starting_rev = starting_rev.revision
+
def do_stamp(rev, context):
- if sql:
- current = False
- else:
- current = context._current_rev()
- dest = script.get_revision(revision)
- if dest is not None:
- dest = dest.revision
- context._update_current_rev(current, dest)
- return []
+ return script._stamp_revs(revision, rev)
+
with EnvironmentContext(
config,
script,
fn=do_stamp,
as_sql=sql,
destination_rev=revision,
+ starting_rev=starting_rev,
tag=tag
):
script.run_env()
-def splice(config, parent, child):
- """'splice' two branches, creating a new revision file.
-
- this command isn't implemented right now.
-
- """
- raise NotImplementedError()
diff --git a/alembic/compat.py b/alembic/compat.py
index 5cb1fc7..d7a1303 100644
--- a/alembic/compat.py
+++ b/alembic/compat.py
@@ -8,6 +8,7 @@ if sys.version_info < (2, 6):
sqla_08 = sa_version >= '0.8.0'
sqla_09 = sa_version >= '0.9.0'
+py27 = sys.version_info >= (2, 7)
py2k = sys.version_info < (3, 0)
py3k = sys.version_info >= (3, 0)
py33 = sys.version_info >= (3, 3)
@@ -128,6 +129,31 @@ else:
reraise(type(exception), exception, tb=exc_tb)
+if py3k:
+ def reraise(tp, value, tb=None, cause=None):
+ if cause is not None:
+ value.__cause__ = cause
+ if value.__traceback__ is not tb:
+ raise value.with_traceback(tb)
+ raise value
+
+ def raise_from_cause(exception, exc_info=None):
+ if exc_info is None:
+ exc_info = sys.exc_info()
+ exc_type, exc_value, exc_tb = exc_info
+ reraise(type(exception), exception, tb=exc_tb, cause=exc_value)
+else:
+ exec("def reraise(tp, value, tb=None, cause=None):\n"
+ " raise tp, value, tb\n")
+
+ def raise_from_cause(exception, exc_info=None):
+ # not as nice as that of Py3K, but at least preserves
+ # the code line where the issue occurred
+ if exc_info is None:
+ exc_info = sys.exc_info()
+ exc_type, exc_value, exc_tb = exc_info
+ reraise(type(exception), exception, tb=exc_tb)
+
# produce a wrapper that allows encoded text to stream
# into a given buffer, but doesn't close it.
# not sure of a more idiomatic approach to this.
diff --git a/alembic/config.py b/alembic/config.py
index b29a888..7d1beaf 100644
--- a/alembic/config.py
+++ b/alembic/config.py
@@ -203,55 +203,108 @@ class CommandLine(object):
def _generate_args(self, prog):
def add_options(parser, positional, kwargs):
- if 'template' in kwargs:
- parser.add_argument("-t", "--template",
- default='generic',
- type=str,
- help="Setup template for use with 'init'")
- if 'message' in kwargs:
- parser.add_argument(
+ kwargs_opts = {
+ 'template': (
+ "-t", "--template",
+ dict(
+ default='generic',
+ type=str,
+ help="Setup template for use with 'init'"
+ )
+ ),
+ 'message': (
"-m", "--message",
- type=str,
- help="Message string to use with 'revision'")
- if 'sql' in kwargs:
- parser.add_argument(
+ dict(
+ type=str,
+ help="Message string to use with 'revision'")
+ ),
+ 'sql': (
"--sql",
- action="store_true",
- help="Don't emit SQL to database - dump to "
- "standard output/file instead")
- if 'tag' in kwargs:
- parser.add_argument(
+ dict(
+ action="store_true",
+ help="Don't emit SQL to database - dump to "
+ "standard output/file instead"
+ )
+ ),
+ 'tag': (
"--tag",
- type=str,
- help="Arbitrary 'tag' name - can be used by "
- "custom env.py scripts.")
- if 'autogenerate' in kwargs:
- parser.add_argument(
+ dict(
+ type=str,
+ help="Arbitrary 'tag' name - can be used by "
+ "custom env.py scripts.")
+ ),
+ 'head': (
+ "--head",
+ dict(
+ type=str,
+ help="Specify head revision or <branchname>@head "
+ "to base new revision on."
+ )
+ ),
+ 'splice': (
+ "--splice",
+ dict(
+ action="store_true",
+ help="Allow a non-head revision as the "
+ "'head' to splice onto"
+ )
+ ),
+ 'branch_label': (
+ "--branch-label",
+ dict(
+ type=str,
+ help="Specify a branch label to apply to the "
+ "new revision"
+ )
+ ),
+ 'verbose': (
+ "-v", "--verbose",
+ dict(
+ action="store_true",
+ help="Use more verbose output"
+ )
+ ),
+ 'autogenerate': (
"--autogenerate",
- action="store_true",
- help="Populate revision script with candidate "
- "migration operations, based on comparison "
- "of database to model.")
- # "current" command
- if 'head_only' in kwargs:
- parser.add_argument(
+ dict(
+ action="store_true",
+ help="Populate revision script with candidate "
+ "migration operations, based on comparison "
+ "of database to model.")
+ ),
+ 'head_only': (
"--head-only",
- action="store_true",
- help="Only show current version and "
- "whether or not this is the head revision.")
-
- if 'rev_range' in kwargs:
- parser.add_argument("-r", "--rev-range",
- action="store",
- help="Specify a revision range; "
- "format is [start]:[end]")
-
+ dict(
+ action="store_true",
+ help="Deprecated. Use --verbose for "
+ "additional output")
+ ),
+ 'rev_range': (
+ "-r", "--rev-range",
+ dict(
+ action="store",
+ help="Specify a revision range; "
+ "format is [start]:[end]")
+ )
+ }
positional_help = {
'directory': "location of scripts directory",
- 'revision': "revision identifier"
+ 'revision': "revision identifier",
+ 'revisions': "one or more revisions, or 'heads' for all heads"
+
}
+ for arg in kwargs:
+ if arg in kwargs_opts:
+ args = kwargs_opts[arg]
+ args, kw = args[0:-1], args[-1]
+ parser.add_argument(*args, **kw)
+
for arg in positional:
- subparser.add_argument(arg, help=positional_help.get(arg))
+ if arg == "revisions":
+ subparser.add_argument(
+ arg, nargs='+', help=positional_help.get(arg))
+ else:
+ subparser.add_argument(arg, help=positional_help.get(arg))
parser = ArgumentParser(prog=prog)
parser.add_argument("-c", "--config",
@@ -267,7 +320,8 @@ class CommandLine(object):
help="Additional arguments consumed by "
"custom env.py scripts, e.g. -x "
"setting1=somesetting -x setting2=somesetting")
-
+ parser.add_argument("--raiseerr", action="store_true",
+ help="Raise a full stack trace on error")
subparsers = parser.add_subparsers()
for fn in [getattr(command, n) for n in dir(command)]:
@@ -299,7 +353,10 @@ class CommandLine(object):
**dict((k, getattr(options, k)) for k in kwarg)
)
except util.CommandError as e:
- util.err(str(e))
+ if options.raiseerr:
+ raise
+ else:
+ util.err(str(e))
def main(self, argv=None):
options = self.parser.parse_args(argv)
diff --git a/alembic/ddl/impl.py b/alembic/ddl/impl.py
index da77052..b069e47 100644
--- a/alembic/ddl/impl.py
+++ b/alembic/ddl/impl.py
@@ -104,7 +104,7 @@ class DefaultImpl(with_metaclass(ImplMeta)):
conn = self.connection
if execution_options:
conn = conn.execution_options(**execution_options)
- conn.execute(construct, *multiparams, **params)
+ return conn.execute(construct, *multiparams, **params)
def execute(self, sql, execution_options=None):
self._exec(sql, execution_options)
diff --git a/alembic/environment.py b/alembic/environment.py
index 5dd9c6f..dab31e8 100644
--- a/alembic/environment.py
+++ b/alembic/environment.py
@@ -136,13 +136,33 @@ class EnvironmentContext(object):
return not self.is_offline_mode()
def get_head_revision(self):
- """Return the hex identifier of the 'head' revision.
+ """Return the hex identifier of the 'head' script revision.
+
+ If the script directory has multiple heads, this
+ method raises a :class:`.CommandError`;
+ :meth:`.EnvironmentContext.get_head_revisions` should be preferred.
This function does not require that the :class:`.MigrationContext`
has been configured.
+ .. seealso:: :meth:`.EnvironmentContext.get_head_revisions`
+
"""
- return self.script._as_rev_number("head")
+ return self.script.as_revision_number("head")
+
+ def get_head_revisions(self):
+ """Return the hex identifier of the 'heads' script revision(s).
+
+ This returns a tuple containing the version number of all
+ heads in the script directory.
+
+ This function does not require that the :class:`.MigrationContext`
+ has been configured.
+
+ .. versionadded:: 0.7.0
+
+ """
+ return self.script.as_revision_number("heads")
def get_starting_revision_argument(self):
"""Return the 'starting revision' argument,
@@ -157,12 +177,16 @@ class EnvironmentContext(object):
"""
if self._migration_context is not None:
- return self.script._as_rev_number(
+ return self.script.as_revision_number(
self.get_context()._start_from_rev)
elif 'starting_rev' in self.context_opts:
- return self.script._as_rev_number(
+ return self.script.as_revision_number(
self.context_opts['starting_rev'])
else:
+ # this should raise only in the case that a command
+ # is being run where the "starting rev" is never applicable;
+ # this is to catch scripts which rely upon this in
+ # non-sql mode or similar
raise util.CommandError(
"No starting revision argument is available.")
@@ -180,7 +204,7 @@ class EnvironmentContext(object):
has been configured.
"""
- return self.script._as_rev_number(
+ return self.script.as_revision_number(
self.context_opts['destination_rev'])
def get_tag_argument(self):
@@ -342,8 +366,6 @@ class EnvironmentContext(object):
:param output_encoding: when using ``--sql`` to generate SQL
scripts, apply this encoding to the string output.
- .. versionadded:: 0.5.0
-
:param starting_rev: Override the "starting revision" argument
when using ``--sql`` mode.
:param tag: a string tag for usage by custom ``env.py`` scripts.
@@ -355,15 +377,11 @@ class EnvironmentContext(object):
option is used, or if the option "revision_environment=true"
is present in the alembic.ini file.
- .. versionadded:: 0.3.3
-
:param version_table: The name of the Alembic version table.
The default is ``'alembic_version'``.
:param version_table_schema: Optional schema to place version
table within.
- .. versionadded:: 0.5.0
-
Parameters specific to the autogenerate feature, when
``alembic revision`` is run with the ``--autogenerate`` feature:
@@ -558,8 +576,6 @@ class EnvironmentContext(object):
option to specify a callable which
can filter the tables/schemas that get included.
- .. versionadded :: 0.4.0
-
.. seealso::
:paramref:`.EnvironmentContext.configure.include_object`
@@ -589,8 +605,6 @@ class EnvironmentContext(object):
``"primary_key"``, ``"foreign_key"``, ``"unique"``, ``"check"``,
``"type"``, ``"server_default"``.
- .. versionadded:: 0.5.0
-
.. seealso::
:ref:`autogen_render_types`
diff --git a/alembic/migration.py b/alembic/migration.py
index a316aa3..7d509af 100644
--- a/alembic/migration.py
+++ b/alembic/migration.py
@@ -2,7 +2,6 @@ import logging
import sys
from contextlib import contextmanager
-
from sqlalchemy import MetaData, Table, Column, String, literal_column
from sqlalchemy import create_engine
from sqlalchemy.engine import url as sqla_url
@@ -187,37 +186,83 @@ class MigrationContext(object):
"""Return the current revision, usually that which is present
in the ``alembic_version`` table in the database.
+ This method intends to be used only for a migration stream that
+ does not contain unmerged branches in the target database;
+ if there are multiple branches present, an exception is raised.
+ The :meth:`.MigrationContext.get_current_heads` should be preferred
+ over this method going forward in order to be compatible with
+ branch migration support.
+
If this :class:`.MigrationContext` was configured in "offline"
mode, that is with ``as_sql=True``, the ``starting_rev``
parameter is returned instead, if any.
"""
+ heads = self.get_current_heads()
+ if len(heads) == 0:
+ return None
+ elif len(heads) > 1:
+ raise util.CommandError(
+ "Version table '%s' has more than one head present; "
+ "please use get_current_heads()" % self.version_table)
+ else:
+ return heads[0]
+
+ def get_current_heads(self):
+ """Return a tuple of the current 'head versions' that are represented
+ in the target database.
+
+ For a migration stream without branches, this will be a single
+ value, synonymous with that of
+ :meth:`.MigrationContext.get_current_revision`. However when multiple
+ unmerged branches exist within the target database, the returned tuple
+ will contain a value for each head.
+
+ If this :class:`.MigrationContext` was configured in "offline"
+ mode, that is with ``as_sql=True``, the ``starting_rev``
+ parameter is returned in a one-length tuple.
+
+ If no version table is present, or if there are no revisions
+ present, an empty tuple is returned.
+
+ .. versionadded:: 0.7.0
+
+ """
if self.as_sql:
- return self._start_from_rev
+ return util.to_tuple(self._start_from_rev, default=())
else:
if self._start_from_rev:
raise util.CommandError(
"Can't specify current_rev to context "
"when using a database connection")
- self._version.create(self.connection, checkfirst=True)
- return self.connection.scalar(self._version.select())
-
- _current_rev = get_current_revision
- """The 0.2 method name, for backwards compat."""
-
- def _update_current_rev(self, old, new):
- if old == new:
- return
- if new is None:
- self.impl._exec(self._version.delete())
- elif old is None:
- self.impl._exec(self._version.insert().
- values(version_num=literal_column("'%s'" % new))
- )
- else:
- self.impl._exec(self._version.update().
- values(version_num=literal_column("'%s'" % new))
- )
+ if not self._has_version_table():
+ return ()
+ return tuple(
+ row[0] for row in self.connection.execute(self._version.select())
+ )
+
+ def _ensure_version_table(self):
+ self._version.create(self.connection, checkfirst=True)
+
+ def _has_version_table(self):
+ return self.connection.dialect.has_table(
+ self.connection, self.version_table, self.version_table_schema)
+
+ def stamp(self, script_directory, revision):
+ """Stamp the version table with a specific revision.
+
+ This method calculates those branches to which the given revision
+ can apply, and updates those branches as though they were migrated
+ towards that revision (either up or down). If no current branches
+ include the revision, it is added as a new branch head.
+
+ .. versionadded:: 0.7.0
+
+ """
+ heads = self.get_current_heads()
+ head_maintainer = HeadMaintainer(self, heads)
+ for step in script_directory._steps_revs(revision, heads):
+ head_maintainer.update_to_step(step)
def run_migrations(self, **kw):
"""Run the migration scripts established for this
@@ -240,42 +285,34 @@ class MigrationContext(object):
method within revision scripts.
"""
- current_rev = rev = False
- stamp_per_migration = not self.impl.transactional_ddl or \
- self._transaction_per_migration
-
self.impl.start_migrations()
- for change, prev_rev, rev, doc in self._migrations_fn(
- self.get_current_revision(),
- self):
+
+ heads = self.get_current_heads()
+ if not self.as_sql and not heads:
+ self._ensure_version_table()
+
+ head_maintainer = HeadMaintainer(self, heads)
+
+ for step in self._migrations_fn(heads, self):
with self.begin_transaction(_per_migration=True):
- if current_rev is False:
- current_rev = prev_rev
- if self.as_sql and not current_rev:
- self._version.create(self.connection)
- if doc:
- log.info(
- "Running %s %s -> %s, %s", change.__name__, prev_rev,
- rev, doc)
- else:
- log.info(
- "Running %s %s -> %s", change.__name__, prev_rev, rev)
+ if self.as_sql and not head_maintainer.heads:
+ # for offline mode, include a CREATE TABLE from
+ # the base
+ self._version.create(self.connection)
+ log.info("Running %s", step)
if self.as_sql:
- self.impl.static_output(
- "-- Running %s %s -> %s" %
- (change.__name__, prev_rev, rev)
- )
- change(**kw)
- if stamp_per_migration:
- self._update_current_rev(prev_rev, rev)
- prev_rev = rev
-
- if rev is not False:
- if not stamp_per_migration:
- self._update_current_rev(current_rev, rev)
-
- if self.as_sql and not rev:
- self._version.drop(self.connection)
+ self.impl.static_output("-- Running %s" % (step.short_log,))
+ step.migration_fn(**kw)
+
+ # previously, we wouldn't stamp per migration
+ # if we were in a transaction, however given the more
+ # complex model that involves any number of inserts
+ # and row-targeted updates and deletes, it's simpler for now
+ # just to run the operations on every version
+ head_maintainer.update_to_step(step)
+
+ if self.as_sql and not head_maintainer.heads:
+ self._version.drop(self.connection)
def execute(self, sql, execution_options=None):
"""Execute a SQL construct or string statement.
@@ -372,3 +409,339 @@ class MigrationContext(object):
metadata_column,
rendered_metadata_default,
rendered_column_default)
+
+
+class HeadMaintainer(object):
+ def __init__(self, context, heads):
+ self.context = context
+ self.heads = set(heads)
+
+ def _insert_version(self, version):
+ assert version not in self.heads
+ self.heads.add(version)
+
+ self.context.impl._exec(
+ self.context._version.insert().
+ values(
+ version_num=literal_column("'%s'" % version)
+ )
+ )
+
+ def _delete_version(self, version):
+ self.heads.remove(version)
+
+ ret = self.context.impl._exec(
+ self.context._version.delete().where(
+ self.context._version.c.version_num ==
+ literal_column("'%s'" % version)))
+ if not self.context.as_sql and ret.rowcount != 1:
+ raise util.CommandError(
+ "Online migration expected to match one "
+ "row when deleting '%s' in '%s'; "
+ "%d found"
+ % (version,
+ self.context.version_table, ret.rowcount))
+
+ def _update_version(self, from_, to_):
+ assert to_ not in self.heads
+ self.heads.remove(from_)
+ self.heads.add(to_)
+
+ ret = self.context.impl._exec(
+ self.context._version.update().
+ values(version_num=literal_column("'%s'" % to_)).where(
+ self.context._version.c.version_num
+ == literal_column("'%s'" % from_))
+ )
+ if not self.context.as_sql and ret.rowcount != 1:
+ raise util.CommandError(
+ "Online migration expected to match one "
+ "row when updating '%s' to '%s' in '%s'; "
+ "%d found"
+ % (from_, to_, self.context.version_table, ret.rowcount))
+
+ def update_to_step(self, step):
+ if step.should_delete_branch(self.heads):
+ vers = step.delete_version_num
+ log.debug("branch delete %s", vers)
+ self._delete_version(vers)
+ elif step.should_create_branch(self.heads):
+ vers = step.insert_version_num
+ log.debug("new branch insert %s", vers)
+ self._insert_version(vers)
+ elif step.should_merge_branches(self.heads):
+ # delete revs, update from rev, update to rev
+ (delete_revs, update_from_rev,
+ update_to_rev) = step.merge_branch_idents
+ log.debug(
+ "merge, delete %s, update %s to %s",
+ delete_revs, update_from_rev, update_to_rev)
+ for delrev in delete_revs:
+ self._delete_version(delrev)
+ self._update_version(update_from_rev, update_to_rev)
+ elif step.should_unmerge_branches(self.heads):
+ (update_from_rev, update_to_rev,
+ insert_revs) = step.unmerge_branch_idents
+ log.debug(
+ "unmerge, insert %s, update %s to %s",
+ insert_revs, update_from_rev, update_to_rev)
+ for insrev in insert_revs:
+ self._insert_version(insrev)
+ self._update_version(update_from_rev, update_to_rev)
+ else:
+ from_, to_ = step.update_version_num
+ log.debug("update %s to %s", from_, to_)
+ self._update_version(from_, to_)
+
+
+class MigrationStep(object):
+ @property
+ def name(self):
+ return self.migration_fn.__name__
+
+ @classmethod
+ def upgrade_from_script(cls, revision_map, script):
+ return RevisionStep(revision_map, script, True)
+
+ @classmethod
+ def downgrade_from_script(cls, revision_map, script):
+ return RevisionStep(revision_map, script, False)
+
+ @property
+ def is_downgrade(self):
+ return not self.is_upgrade
+
+ @property
+ def merge_branch_idents(self):
+ return (
+ # delete revs, update from rev, update to rev
+ list(self.from_revisions[0:-1]), self.from_revisions[-1],
+ self.to_revisions[0]
+ )
+
+ @property
+ def unmerge_branch_idents(self):
+ return (
+ # update from rev, update to rev, insert revs
+ self.from_revisions[0], self.to_revisions[-1],
+ list(self.to_revisions[0:-1])
+ )
+
+ @property
+ def short_log(self):
+ return "%s %s -> %s" % (
+ self.name,
+ util.format_as_comma(self.from_revisions),
+ util.format_as_comma(self.to_revisions)
+ )
+
+ def __str__(self):
+ if self.doc:
+ return "%s %s -> %s, %s" % (
+ self.name,
+ util.format_as_comma(self.from_revisions),
+ util.format_as_comma(self.to_revisions),
+ self.doc
+ )
+ else:
+ return self.short_log
+
+
+class RevisionStep(MigrationStep):
+ def __init__(self, revision_map, revision, is_upgrade):
+ self.revision_map = revision_map
+ self.revision = revision
+ self.is_upgrade = is_upgrade
+ if is_upgrade:
+ self.migration_fn = revision.module.upgrade
+ else:
+ self.migration_fn = revision.module.downgrade
+
+ def __eq__(self, other):
+ return isinstance(other, RevisionStep) and \
+ other.revision == self.revision and \
+ self.is_upgrade == other.is_upgrade
+
+ @property
+ def doc(self):
+ return self.revision.doc
+
+ @property
+ def from_revisions(self):
+ if self.is_upgrade:
+ return self.revision._down_revision_tuple
+ else:
+ return (self.revision.revision, )
+
+ @property
+ def to_revisions(self):
+ if self.is_upgrade:
+ return (self.revision.revision, )
+ else:
+ return self.revision._down_revision_tuple
+
+ @property
+ def _has_scalar_down_revision(self):
+ return len(self.revision._down_revision_tuple) == 1
+
+ def should_delete_branch(self, heads):
+ if not self.is_downgrade:
+ return False
+
+ if self.revision.revision not in heads:
+ return False
+
+ downrevs = self.revision._down_revision_tuple
+ if not downrevs:
+ # is a base
+ return True
+ elif len(downrevs) == 1:
+ downrev = self.revision_map.get_revision(downrevs[0])
+
+ if not downrev.is_branch_point:
+ return False
+
+ descendants = set(
+ r.revision for r in self.revision_map._get_descendant_nodes(
+ self.revision_map.get_revisions(downrev.nextrev),
+ check=False
+ )
+ )
+
+ # the downrev is a branchpoint, and other members or descendants
+ # of the branch are still in heads; so delete this branch.
+ # the reason this occurs is because traversal tries to stay
+ # fully on one branch down to the branchpoint before starting
+ # the other; so if we have a->b->(c1->d1->e1, c2->d2->e2),
+ # on a downgrade from the top we may go e1, d1, c1, now heads
+ # are at c1 and e2, with the current method, we don't know that
+ # "e2" is important unless we get all descendants of c1/c2
+
+ if len(descendants.intersection(heads).difference(
+ [self.revision.revision])):
+
+ # TODO: this doesn't work; make sure tests are here to ensure
+ # this fails
+ #if len(downrev.nextrev.intersection(heads).difference(
+ # [self.revision.revision])):
+
+ return True
+ else:
+ return False
+ else:
+ # is a merge point
+ return False
+
+ def should_create_branch(self, heads):
+ if not self.is_upgrade:
+ return False
+
+ downrevs = self.revision._down_revision_tuple
+
+ if not downrevs:
+ # is a base
+ return True
+ elif len(downrevs) == 1:
+ if downrevs[0] in heads:
+ return False
+ else:
+ return True
+ else:
+ # is a merge point
+ return False
+
+ def should_merge_branches(self, heads):
+ if not self.is_upgrade:
+ return False
+
+ downrevs = self.revision._down_revision_tuple
+
+ if len(downrevs) > 1 and \
+ len(heads.intersection(downrevs)) > 1:
+ return True
+
+ return False
+
+ def should_unmerge_branches(self, heads):
+ if not self.is_downgrade:
+ return False
+
+ downrevs = self.revision._down_revision_tuple
+
+ if self.revision.revision in heads and len(downrevs) > 1:
+ return True
+
+ return False
+
+ @property
+ def update_version_num(self):
+ assert self._has_scalar_down_revision
+ if self.is_upgrade:
+ return self.revision.down_revision, self.revision.revision
+ else:
+ return self.revision.revision, self.revision.down_revision
+
+ @property
+ def delete_version_num(self):
+ return self.revision.revision
+
+ @property
+ def insert_version_num(self):
+ return self.revision.revision
+
+
+class StampStep(MigrationStep):
+ def __init__(self, from_, to_, is_upgrade, branch_move):
+ self.from_ = util.to_tuple(from_, default=())
+ self.to_ = util.to_tuple(to_, default=())
+ self.is_upgrade = is_upgrade
+ self.branch_move = branch_move
+ self.migration_fn = self.stamp_revision
+
+ doc = None
+
+ def stamp_revision(self, **kw):
+ return None
+
+ def __eq__(self, other):
+ return isinstance(other, StampStep) and \
+ other.from_revisions == self.revisions and \
+ other.to_revisions == self.to_revisions and \
+ other.branch_move == self.branch_move and \
+ self.is_upgrade == other.is_upgrade
+
+ @property
+ def from_revisions(self):
+ return self.from_
+
+ @property
+ def to_revisions(self):
+ return self.to_
+
+ @property
+ def delete_version_num(self):
+ assert len(self.from_) == 1
+ return self.from_[0]
+
+ @property
+ def insert_version_num(self):
+ assert len(self.to_) == 1
+ return self.to_[0]
+
+ @property
+ def update_version_num(self):
+ assert len(self.from_) == 1
+ assert len(self.to_) == 1
+ return self.from_[0], self.to_[0]
+
+ def should_delete_branch(self, heads):
+ return self.is_downgrade and self.branch_move
+
+ def should_create_branch(self, heads):
+ return self.is_upgrade and self.branch_move
+
+ def should_merge_branches(self, heads):
+ return len(self.from_) > 1
+
+ def should_unmerge_branches(self, heads):
+ return len(self.to_) > 1
diff --git a/alembic/operations.py b/alembic/operations.py
index f6e9cb0..4665505 100644
--- a/alembic/operations.py
+++ b/alembic/operations.py
@@ -296,8 +296,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -359,12 +357,6 @@ class Operations(object):
Set to ``None`` to have the default removed.
:param new_column_name: Optional; specify a string name here to
indicate the new name within a column rename operation.
-
- .. versionchanged:: 0.5.0
- The ``name`` parameter is now named ``new_column_name``.
- The old name will continue to function for backwards
- compatibility.
-
:param ``type_``: Optional; a :class:`~sqlalchemy.types.TypeEngine`
type object to specify a change to the column's type.
For SQLAlchemy types that also indicate a constraint (i.e.
@@ -398,8 +390,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -543,8 +533,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -578,8 +566,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -636,8 +622,6 @@ class Operations(object):
off normally. The :class:`~sqlalchemy.schema.AddConstraint`
construct is ultimately used to generate the ALTER statement.
- .. versionadded:: 0.5.0
-
:param name: Name of the primary key constraint. The name is necessary
so that an ALTER statement can be emitted. For setups that
use an automated naming scheme such as that described at
@@ -653,8 +637,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -764,8 +746,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -817,8 +797,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -901,8 +879,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
:param \**kw: Other keyword arguments are passed to the underlying
@@ -934,13 +910,9 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
- .. versionadded:: 0.4.0
-
:param \**kw: Other keyword arguments are passed to the underlying
:class:`sqlalchemy.schema.Table` object created for the command.
@@ -974,12 +946,6 @@ class Operations(object):
:param name: name of the index.
:param table_name: name of the owning table.
-
- .. versionchanged:: 0.5.0
- The ``tablename`` parameter is now named ``table_name``.
- As this is a positional argument, the old name is no
- longer present.
-
:param columns: a list consisting of string column names and/or
:func:`~sqlalchemy.sql.expression.text` constructs.
:param schema: Optional schema name to operate within. To control
@@ -987,8 +953,6 @@ class Operations(object):
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -1026,19 +990,11 @@ class Operations(object):
:param name: name of the index.
:param table_name: name of the owning table. Some
backends such as Microsoft SQL Server require this.
-
- .. versionchanged:: 0.5.0
- The ``tablename`` parameter is now named ``table_name``.
- The old name will continue to function for backwards
- compatibility.
-
:param schema: Optional schema name to operate within. To control
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
@@ -1055,29 +1011,13 @@ class Operations(object):
:param name: name of the constraint.
:param table_name: table name.
-
- .. versionchanged:: 0.5.0
- The ``tablename`` parameter is now named ``table_name``.
- As this is a positional argument, the old name is no
- longer present.
-
:param ``type_``: optional, required on MySQL. can be
'foreignkey', 'primary', 'unique', or 'check'.
-
- .. versionchanged:: 0.5.0
- The ``type`` parameter is now named ``type_``. The old name
- ``type`` will remain for backwards compatibility.
-
- .. versionadded:: 0.3.6 'primary' qualfier to enable
- dropping of MySQL primary key constraints.
-
:param schema: Optional schema name to operate within. To control
quoting of the schema outside of the default behavior, use
the SQLAlchemy construct
:class:`~sqlalchemy.sql.elements.quoted_name`.
- .. versionadded:: 0.4.0 support for 'schema'
-
.. versionadded:: 0.7.0 'schema' can now accept a
:class:`~sqlalchemy.sql.elements.quoted_name` construct.
diff --git a/alembic/revision.py b/alembic/revision.py
new file mode 100644
index 0000000..071452f
--- /dev/null
+++ b/alembic/revision.py
@@ -0,0 +1,646 @@
+import re
+import collections
+
+from . import util
+from sqlalchemy import util as sqlautil
+from . import compat
+
+_relative_destination = re.compile(r'(?:(.+?)@)?((?:\+|-)\d+)')
+
+
+class RevisionError(Exception):
+ pass
+
+
+class RangeNotAncestorError(RevisionError):
+ def __init__(self, lower, upper):
+ self.lower = lower
+ self.upper = upper
+ super(RangeNotAncestorError, self).__init__(
+ "Revision %s is not an ancestor of revision %s" %
+ (lower or "base", upper or "base")
+ )
+
+
+class MultipleHeads(RevisionError):
+ def __init__(self, heads, argument):
+ self.heads = heads
+ self.argument = argument
+ super(MultipleHeads, self).__init__(
+ "Multiple heads are present for given argument '%s'; "
+ "%s" % (argument, ", ".join(heads))
+ )
+
+
+class ResolutionError(RevisionError):
+ pass
+
+
+class RevisionMap(object):
+ """Maintains a map of :class:`.Revision` objects.
+
+ :class:`.RevisionMap` is used by :class:`.ScriptDirectory` to maintain
+ and traverse the collection of :class:`.Script` objects, which are
+ themselves instances of :class:`.Revision`.
+
+ """
+
+ def __init__(self, generator):
+ """Construct a new :class:`.RevisionMap`.
+
+ :param generator: a zero-arg callable that will generate an iterable
+ of :class:`.Revision` instances to be used. These are typically
+ :class:`.Script` subclasses within regular Alembic use.
+
+ """
+ self._generator = generator
+
+ @util.memoized_property
+ def heads(self):
+ """All "head" revisions as strings.
+
+ This is normally a tuple of length one,
+ unless unmerged branches are present.
+
+ :return: a tuple of string revision numbers.
+
+ """
+ self._revision_map
+ return self.heads
+
+ @util.memoized_property
+ def bases(self):
+ """All "base" revisions as strings.
+
+ These are revisions that have a ``down_revision`` of None,
+ or empty tuple.
+
+ :return: a tuple of string revision numbers.
+
+ """
+ self._revision_map
+ return self.bases
+
+ @util.memoized_property
+ def _revision_map(self):
+ """memoized attribute, initializes the revision map from the
+ initial collection.
+
+ """
+ map_ = {}
+
+ heads = sqlautil.OrderedSet()
+ self.bases = ()
+
+ has_branch_labels = set()
+ for revision in self._generator():
+
+ if revision.revision in map_:
+ util.warn("Revision %s is present more than once" %
+ revision.revision)
+ map_[revision.revision] = revision
+ if revision.branch_labels:
+ has_branch_labels.add(revision)
+ heads.add(revision.revision)
+ if revision.is_base:
+ self.bases += (revision.revision, )
+
+ for rev in map_.values():
+ for downrev in rev._down_revision_tuple:
+ if downrev not in map_:
+ util.warn("Revision %s referenced from %s is not present"
+ % (rev.down_revision, rev))
+ down_revision = map_[downrev]
+ down_revision.add_nextrev(rev.revision)
+ heads.discard(downrev)
+
+ map_[None] = map_[()] = None
+ self.heads = tuple(heads)
+
+ for revision in has_branch_labels:
+ self._add_branches(revision, map_)
+ return map_
+
+ def _add_branches(self, revision, map_):
+ if revision.branch_labels:
+ for branch_label in revision._orig_branch_labels:
+ if branch_label in map_:
+ raise RevisionError(
+ "Branch name '%s' in revision %s already "
+ "used by revision %s" %
+ (branch_label, revision.revision,
+ map_[branch_label].revision)
+ )
+ map_[branch_label] = revision
+ revision.branch_labels.update(revision.branch_labels)
+ for node in self._get_descendant_nodes([revision], map_):
+ node.branch_labels.update(revision.branch_labels)
+
+ parent = node
+ while parent and \
+ not parent.is_branch_point and not parent.is_merge_point:
+
+ parent.branch_labels.update(revision.branch_labels)
+ if parent.down_revision:
+ parent = map_[parent.down_revision]
+ else:
+ break
+
+ def add_revision(self, revision, _replace=False):
+ """add a single revision to an existing map.
+
+ This method is for single-revision use cases, it's not
+ appropriate for fully populating an entire revision map.
+
+ """
+ map_ = self._revision_map
+ if not _replace and revision.revision in map_:
+ util.warn("Revision %s is present more than once" %
+ revision.revision)
+ elif _replace and revision.revision not in map_:
+ raise Exception("revision %s not in map" % revision.revision)
+
+ map_[revision.revision] = revision
+ self._add_branches(revision, map_)
+ if revision.is_base:
+ self.bases += (revision.revision, )
+ for downrev in revision._down_revision_tuple:
+ if downrev not in map_:
+ util.warn(
+ "Revision %s referenced from %s is not present"
+ % (revision.down_revision, revision)
+ )
+ map_[downrev].add_nextrev(revision.revision)
+ if revision.is_head:
+ self.heads = tuple(
+ head for head in self.heads
+ if head not in
+ set(revision._down_revision_tuple).union([revision.revision])
+ ) + (revision.revision,)
+
+ def get_current_head(self, branch_label=None):
+ """Return the current head revision.
+
+ If the script directory has multiple heads
+ due to branching, an error is raised;
+ :meth:`.ScriptDirectory.get_heads` should be
+ preferred.
+
+ :param branch_label: optional branch name which will limit the
+ heads considered to those which include that branch_label.
+
+ :return: a string revision number.
+
+ .. seealso::
+
+ :meth:`.ScriptDirectory.get_heads`
+
+ """
+ current_heads = self.heads
+ if branch_label:
+ current_heads = self.filter_for_lineage(current_heads, branch_label)
+ if len(current_heads) > 1:
+ raise MultipleHeads(
+ current_heads,
+ "%s@head" % branch_label if branch_label else "head")
+
+ if current_heads:
+ return current_heads[0]
+ else:
+ return None
+
+ def _get_base_revisions(self, identifier):
+ return self.filter_for_lineage(self.bases, identifier)
+
+ def get_revisions(self, id_):
+ """Return the :class:`.Revision` instances with the given rev id
+ or identifiers.
+
+ May be given a single identifier, a sequence of identifiers, or the
+ special symbols "head" or "base". The result is a tuple of one
+ or more identifiers.
+
+ Supports partial identifiers, where the given identifier
+ is matched against all identifiers that start with the given
+ characters; if there is exactly one match, that determines the
+ full revision.
+
+ """
+ if isinstance(id_, (list, tuple, set, frozenset)):
+ return sum([self.get_revisions(id_elem) for id_elem in id_], ())
+ else:
+ resolved_id, branch_label = self._resolve_revision_number(id_)
+ return tuple(
+ self._revision_for_ident(rev_id, branch_label)
+ for rev_id in resolved_id)
+
+ def get_revision(self, id_):
+ """Return the :class:`.Revision` instance with the given rev id.
+
+ If a symbolic name such as "head" or "base" is given, resolves
+ the identifier into the current head or base revision. If the symbolic
+ name refers to multiples, :class:`.MultipleHeads` is raised.
+
+ Supports partial identifiers, where the given identifier
+ is matched against all identifiers that start with the given
+ characters; if there is exactly one match, that determines the
+ full revision.
+
+ """
+
+ resolved_id, branch_label = self._resolve_revision_number(id_)
+ if len(resolved_id) > 1:
+ raise MultipleHeads(resolved_id, id_)
+ elif resolved_id:
+ resolved_id = resolved_id[0]
+
+ return self._revision_for_ident(resolved_id, branch_label)
+
+ def _resolve_branch(self, branch_label):
+ try:
+ branch_rev = self._revision_map[branch_label]
+ except KeyError:
+ try:
+ nonbranch_rev = self._revision_for_ident(branch_label)
+ except ResolutionError:
+ raise ResolutionError("No such branch: '%s'" % branch_label)
+ else:
+ return nonbranch_rev
+ else:
+ return branch_rev
+
+ def _revision_for_ident(self, resolved_id, check_branch=None):
+ if check_branch:
+ branch_rev = self._resolve_branch(check_branch)
+ else:
+ branch_rev = None
+
+ try:
+ revision = self._revision_map[resolved_id]
+ except KeyError:
+ # do a partial lookup
+ revs = [x for x in self._revision_map
+ if x and x.startswith(resolved_id)]
+ if branch_rev:
+ revs = self.filter_for_lineage(revs, check_branch)
+ if not revs:
+ raise ResolutionError(
+ "No such revision or branch '%s'" % resolved_id)
+ elif len(revs) > 1:
+ raise ResolutionError(
+ "Multiple revisions start "
+ "with '%s': %s..." % (
+ resolved_id,
+ ", ".join("'%s'" % r for r in revs[0:3])
+ ))
+ else:
+ revision = self._revision_map[revs[0]]
+
+ if check_branch and revision is not None:
+ if not self._shares_lineage(
+ revision.revision, branch_rev.revision):
+ raise ResolutionError(
+ "Revision %s is not a member of branch '%s'" %
+ (revision.revision, check_branch))
+ return revision
+
+ def filter_for_lineage(self, targets, check_against):
+ id_, branch_label = self._resolve_revision_number(check_against)
+
+ shares = []
+ if branch_label:
+ shares.append(branch_label)
+ if id_:
+ shares.append(id_[0])
+
+ #shares = branch_label or (id_[0] if id_ else None)
+
+ return [
+ tg for tg in targets
+ if self._shares_lineage(tg, shares)]
+
+ def _shares_lineage(self, target, test_against_revs):
+ if not test_against_revs:
+ return True
+ if not isinstance(target, Revision):
+ target = self._revision_for_ident(target)
+
+ test_against_revs = [
+ self._revision_for_ident(test_against_rev)
+ if not isinstance(test_against_rev, Revision)
+ else test_against_rev
+ for test_against_rev
+ in util.to_tuple(test_against_revs, default=())
+ ]
+
+ return bool(
+ set(self._get_descendant_nodes([target]))
+ .union(self._get_ancestor_nodes([target]))
+ .intersection(test_against_revs)
+ )
+
+ def _resolve_revision_number(self, id_):
+ if isinstance(id_, compat.string_types) and "@" in id_:
+ branch_label, id_ = id_.split('@', 1)
+ else:
+ branch_label = None
+
+ # ensure map is loaded
+ self._revision_map
+ if id_ == 'heads':
+ if branch_label:
+ return self.filter_for_lineage(
+ self.heads, branch_label), branch_label
+ else:
+ return self.heads, branch_label
+ elif id_ == 'head':
+ return (self.get_current_head(branch_label), ), branch_label
+ elif id_ == 'base' or id_ is None:
+ return (), branch_label
+ else:
+ return util.to_tuple(id_, default=None), branch_label
+
+ def iterate_revisions(
+ self, upper, lower, implicit_base=False, inclusive=False,
+ assert_relative_length=True):
+ """Iterate through script revisions, starting at the given
+ upper revision identifier and ending at the lower.
+
+ The traversal uses strictly the `down_revision`
+ marker inside each migration script, so
+ it is a requirement that upper >= lower,
+ else you'll get nothing back.
+
+ The iterator yields :class:`.Revision` objects.
+
+ """
+
+ if isinstance(upper, compat.string_types) and \
+ _relative_destination.match(upper):
+
+ reldelta = 1 if inclusive else 0
+ match = _relative_destination.match(upper)
+ relative = int(match.group(2))
+ branch_label = match.group(1)
+ if branch_label:
+ from_ = "%s@head" % branch_label
+ else:
+ from_ = "head"
+ revs = list(
+ self._iterate_revisions(
+ from_, lower,
+ inclusive=inclusive, implicit_base=implicit_base))
+ revs = revs[-relative - reldelta:]
+ if assert_relative_length and \
+ len(revs) != abs(relative) + reldelta:
+ raise RevisionError(
+ "Relative revision %s didn't "
+ "produce %d migrations" % (upper, abs(relative)))
+ return iter(revs)
+ elif isinstance(lower, compat.string_types) and \
+ _relative_destination.match(lower):
+ reldelta = 1 if inclusive else 0
+ match = _relative_destination.match(lower)
+ relative = int(match.group(2))
+ branch_label = match.group(1)
+
+ if branch_label:
+ to_ = "%s@base" % branch_label
+ else:
+ to_ = "base"
+
+ revs = list(
+ self._iterate_revisions(
+ upper, to_,
+ inclusive=inclusive, implicit_base=implicit_base))
+ revs = revs[0:-relative + reldelta]
+ if assert_relative_length and \
+ len(revs) != abs(relative) + reldelta:
+ raise RevisionError(
+ "Relative revision %s didn't "
+ "produce %d migrations" % (lower, abs(relative)))
+ return iter(revs)
+ else:
+ return self._iterate_revisions(
+ upper, lower, inclusive=inclusive, implicit_base=implicit_base)
+
+ def _get_descendant_nodes(self, targets, map_=None, check=False):
+ return self._iterate_related_revisions(
+ lambda rev: rev.nextrev,
+ targets, map_=map_, check=check
+ )
+
+ def _get_ancestor_nodes(self, targets, map_=None, check=False):
+ return self._iterate_related_revisions(
+ lambda rev: rev._down_revision_tuple,
+ targets, map_=map_, check=check
+ )
+
+ def _iterate_related_revisions(self, fn, targets, map_, check=False):
+ if map_ is None:
+ map_ = self._revision_map
+
+ todo = collections.deque()
+ for target in targets:
+ todo.append(target)
+ if check:
+ per_target = set()
+ while todo:
+ rev = todo.pop()
+ todo.extend(
+ map_[rev_id] for rev_id in fn(rev))
+ if check:
+ per_target.add(rev)
+ yield rev
+ if check and per_target.intersection(targets).difference([target]):
+ raise RevisionError(
+ "Requested revision %s overlaps with "
+ "other requested revisions" % target.revision)
+
+ def _iterate_revisions(
+ self, upper, lower, inclusive=True, implicit_base=False):
+ """iterate revisions from upper to lower.
+
+ The traversal is depth-first within branches, and breadth-first
+ across branches as a whole.
+
+ """
+
+ requested_lowers = self.get_revisions(lower)
+
+ # some complexity to accommodate an iteration where some
+ # branches are starting from nothing, and others are starting
+ # from a given point. Additionally, if the bottom branch
+ # is specified using a branch identifier, then we limit operations
+ # to just that branch.
+
+ limit_to_lower_branch = \
+ isinstance(lower, compat.string_types) and lower.endswith('@base')
+
+ uppers = self.get_revisions(upper)
+ upper_ancestors = set(self._get_ancestor_nodes(uppers, check=True))
+
+ if limit_to_lower_branch:
+ lowers = self.get_revisions(self._get_base_revisions(lower))
+ elif implicit_base and requested_lowers:
+ lower_ancestors = set(
+ self._get_ancestor_nodes(requested_lowers)
+ )
+ lower_descendants = set(
+ self._get_descendant_nodes(requested_lowers)
+ )
+ base_lowers = set()
+ candidate_lowers = upper_ancestors.\
+ difference(lower_ancestors).\
+ difference(lower_descendants)
+ for rev in candidate_lowers:
+ for downrev in rev._down_revision_tuple:
+ if self._revision_map[downrev] in candidate_lowers:
+ break
+ else:
+ base_lowers.add(rev)
+ lowers = base_lowers.union(requested_lowers)
+ elif implicit_base:
+ base_lowers = set(self.get_revisions(self.bases))
+ lowers = base_lowers.union(requested_lowers)
+ elif not requested_lowers:
+ lowers = set(self.get_revisions(self.bases))
+ else:
+ lowers = requested_lowers
+
+ # represents all nodes we will produce
+ total_space = set(
+ rev.revision for rev in upper_ancestors).intersection(
+ rev.revision for rev
+ in self._get_descendant_nodes(lowers, check=True)
+ )
+
+ if not total_space:
+ raise RangeNotAncestorError(lower, upper)
+
+ # organize branch points to be consumed separately from
+ # member nodes
+ branch_todo = set(
+ rev for rev in
+ (self._revision_map[rev] for rev in total_space)
+ if rev.is_branch_point and
+ len(total_space.intersection(rev.nextrev)) > 1
+ )
+
+ # it's not possible for any "uppers" to be in branch_todo,
+ # because the .nextrev of those nodes is not in total_space
+ #assert not branch_todo.intersection(uppers)
+
+ todo = collections.deque(
+ r for r in uppers if r.revision in total_space)
+
+ # iterate for total_space being emptied out
+ while total_space:
+ # when everything non-branch pending is consumed,
+ # add to the todo any branch nodes that have no
+ # descendants left in the queue
+ if not todo:
+ todo.extendleft(
+ rev for rev in branch_todo
+ if not rev.nextrev.intersection(total_space)
+ )
+ branch_todo.difference_update(todo)
+
+ # iterate nodes that are in the immediate todo
+ while todo:
+ rev = todo.popleft()
+ total_space.remove(rev.revision)
+
+ # do depth first for elements within branches,
+ # don't consume any actual branch nodes
+ todo.extendleft([
+ self._revision_map[downrev]
+ for downrev in reversed(rev._down_revision_tuple)
+ if self._revision_map[downrev] not in branch_todo
+ and downrev in total_space])
+
+ if not inclusive and rev in requested_lowers:
+ continue
+ yield rev
+
+ assert not branch_todo
+
+
+class Revision(object):
+ """Base class for revisioned objects.
+
+ The :class:`.Revision` class is the base of the more public-facing
+ :class:`.Script` object, which represents a migration script.
+ The mechanics of revision management and traversal are encapsulated
+ within :class:`.Revision`, while :class:`.Script` applies this logic
+ to Python files in a version directory.
+
+ """
+ nextrev = frozenset()
+
+ revision = None
+ """The string revision number."""
+
+ down_revision = None
+ """The ``down_revision`` identifier(s) within the migration script."""
+
+ branch_labels = None
+ """Optional string/tuple of symbolic names to apply to this
+ revision's branch"""
+
+ def __init__(self, revision, down_revision, branch_labels=None):
+ self.revision = revision
+ self.down_revision = tuple_rev_as_scalar(down_revision)
+ self._orig_branch_labels = util.to_tuple(branch_labels, default=())
+ self.branch_labels = set(self._orig_branch_labels)
+
+ def add_nextrev(self, rev):
+ self.nextrev = self.nextrev.union([rev])
+
+ @property
+ def _down_revision_tuple(self):
+ return util.to_tuple(self.down_revision, default=())
+
+ @property
+ def is_head(self):
+ """Return True if this :class:`.Revision` is a 'head' revision.
+
+ This is determined based on whether any other :class:`.Script`
+ within the :class:`.ScriptDirectory` refers to this
+ :class:`.Script`. Multiple heads can be present.
+
+ """
+ return not bool(self.nextrev)
+
+ @property
+ def is_base(self):
+ """Return True if this :class:`.Revision` is a 'base' revision."""
+
+ return self.down_revision is None
+
+ @property
+ def is_branch_point(self):
+ """Return True if this :class:`.Script` is a branch point.
+
+ A branchpoint is defined as a :class:`.Script` which is referred
+ to by more than one succeeding :class:`.Script`, that is more
+ than one :class:`.Script` has a `down_revision` identifier pointing
+ here.
+
+ """
+ return len(self.nextrev) > 1
+
+ @property
+ def is_merge_point(self):
+ """Return True if this :class:`.Script` is a merge point."""
+
+ return len(self._down_revision_tuple) > 1
+
+
+def tuple_rev_as_scalar(rev):
+ if not rev:
+ return None
+ elif len(rev) == 1:
+ return rev[0]
+ else:
+ return rev
diff --git a/alembic/script.py b/alembic/script.py
index 9d33671..1835605 100644
--- a/alembic/script.py
+++ b/alembic/script.py
@@ -3,6 +3,11 @@ import os
import re
import shutil
from . import util
+from . import compat
+from . import revision
+from . import migration
+
+from contextlib import contextmanager
_sourceless_rev_file = re.compile(r'(?!__init__)(.*\.py)(c|o)?$')
_only_source_rev_file = re.compile(r'(?!__init__)(.*\.py)$')
@@ -10,7 +15,6 @@ _legacy_rev = re.compile(r'([a-f0-9]+)\.py$')
_mod_def_re = re.compile(r'(upgrade|downgrade)_([a-z0-9]+)')
_slug_re = re.compile(r'\w+')
_default_file_template = "%(rev)s_%(slug)s"
-_relative_destination = re.compile(r'(?:\+|-)\d+')
class ScriptDirectory(object):
@@ -43,12 +47,20 @@ class ScriptDirectory(object):
self.truncate_slug_length = truncate_slug_length or 40
self.sourceless = sourceless
self.output_encoding = output_encoding
+ self.revision_map = revision.RevisionMap(self._load_revisions)
if not os.access(dir, os.F_OK):
raise util.CommandError("Path doesn't exist: %r. Please use "
"the 'init' command to create a new "
"scripts folder." % dir)
+ def _load_revisions(self):
+ for file_ in os.listdir(self.versions):
+ script = Script._from_filename(self, self.versions, file_)
+ if script is None:
+ continue
+ yield script
+
@classmethod
def from_config(cls, config):
"""Produce a new :class:`.ScriptDirectory` given a :class:`.Config`
@@ -75,65 +87,94 @@ class ScriptDirectory(object):
output_encoding=config.get_main_option("output_encoding", "utf-8")
)
- def walk_revisions(self, base="base", head="head"):
+ @contextmanager
+ def _catch_revision_errors(
+ self,
+ ancestor=None, multiple_heads=None, start=None, end=None):
+ try:
+ yield
+ except revision.RangeNotAncestorError as rna:
+ if start is None:
+ start = rna.lower
+ if end is None:
+ end = rna.upper
+ if not ancestor:
+ ancestor = (
+ "Requested range %(start)s:%(end)s does not refer to "
+ "ancestor/descendant revisions along the same branch"
+ )
+ ancestor = ancestor % {"start": start, "end": end}
+ compat.raise_from_cause(util.CommandError(ancestor))
+ except revision.MultipleHeads as mh:
+ if not multiple_heads:
+ multiple_heads = (
+ "Multiple head revisions are present for given "
+ "argument '%(head_arg)s'; please "
+ "specify a specific target revision, "
+ "'<branchname>@%(head_arg)s' to "
+ "narrow to a specific head, or 'heads' for all heads")
+ multiple_heads = multiple_heads % {
+ "head_arg": end or mh.argument,
+ "heads": util.format_as_comma(mh.heads)
+ }
+ compat.raise_from_cause(util.CommandError(multiple_heads))
+ except revision.RevisionError as err:
+ compat.raise_from_cause(util.CommandError(err.args[0]))
+
+ def walk_revisions(self, base="base", head="heads"):
"""Iterate through all revisions.
- This is actually a breadth-first tree traversal,
- with leaf nodes being heads.
+ :param base: the base revision, or "base" to start from the
+ empty revision.
+
+ :param head: the head revision; defaults to "heads" to indicate
+ all head revisions. May also be "head" to indicate a single
+ head revision.
+
+ .. versionchanged:: 0.7.0 the "head" identifier now refers to
+ the head of a non-branched repository only; use "heads" to
+ refer to the set of all head branches simultaneously.
"""
- if head == "head":
- heads = set(self.get_heads())
- else:
- heads = set([head])
- while heads:
- todo = set(heads)
- heads = set()
- for head in todo:
- if head in heads:
- break
- for sc in self.iterate_revisions(head, base):
- if sc.is_branch_point and sc.revision not in todo:
- heads.add(sc.revision)
- break
- else:
- yield sc
+ with self._catch_revision_errors(start=base, end=head):
+ for rev in self.revision_map.iterate_revisions(
+ head, base, inclusive=True, assert_relative_length=False):
+ yield rev
+
+ def get_revisions(self, id_):
+ """Return the :class:`.Script` instance with the given rev identifier,
+ symbolic name, or sequence of identifiers.
+
+ .. versionadded:: 0.7.0
+
+ """
+ with self._catch_revision_errors():
+ return self.revision_map.get_revisions(id_)
def get_revision(self, id_):
- """Return the :class:`.Script` instance with the given rev id."""
+ """Return the :class:`.Script` instance with the given rev id.
- id_ = self.as_revision_number(id_)
- try:
- return self._revision_map[id_]
- except KeyError:
- # do a partial lookup
- revs = [x for x in self._revision_map
- if x is not None and x.startswith(id_)]
- if not revs:
- raise util.CommandError("No such revision '%s'" % id_)
- elif len(revs) > 1:
- raise util.CommandError(
- "Multiple revisions start "
- "with '%s', %s..." % (
- id_,
- ", ".join("'%s'" % r for r in revs[0:3])
- ))
- else:
- return self._revision_map[revs[0]]
+ .. seealso::
+
+ :meth:`.ScriptDirectory.get_revisions`
+
+ """
- _get_rev = get_revision
+ with self._catch_revision_errors():
+ return self.revision_map.get_revision(id_)
def as_revision_number(self, id_):
"""Convert a symbolic revision, i.e. 'head' or 'base', into
an actual revision number."""
- if id_ == 'head':
- id_ = self.get_current_head()
- elif id_ == 'base':
- id_ = None
- return id_
+ with self._catch_revision_errors():
+ rev, branch_name = self.revision_map._resolve_revision_number(id_)
- _as_rev_number = as_revision_number
+ if not rev:
+ # convert () to None
+ return None
+ else:
+ return rev[0]
def iterate_revisions(self, upper, lower):
"""Iterate through script revisions, starting at the given
@@ -146,158 +187,47 @@ class ScriptDirectory(object):
The iterator yields :class:`.Script` objects.
- """
- if upper is not None and _relative_destination.match(upper):
- relative = int(upper)
- revs = list(self._iterate_revisions("head", lower))
- revs = revs[-relative:]
- if len(revs) != abs(relative):
- raise util.CommandError(
- "Relative revision %s didn't "
- "produce %d migrations" % (upper, abs(relative)))
- return iter(revs)
- elif lower is not None and _relative_destination.match(lower):
- relative = int(lower)
- revs = list(self._iterate_revisions(upper, "base"))
- revs = revs[0:-relative]
- if len(revs) != abs(relative):
- raise util.CommandError(
- "Relative revision %s didn't "
- "produce %d migrations" % (lower, abs(relative)))
- return iter(revs)
- else:
- return self._iterate_revisions(upper, lower)
-
- def _iterate_revisions(self, upper, lower):
- lower = self.get_revision(lower)
- upper = self.get_revision(upper)
- orig = lower.revision if lower else 'base', \
- upper.revision if upper else 'base'
- script = upper
- while script != lower:
- if script is None and lower is not None:
- raise util.CommandError(
- "Revision %s is not an ancestor of %s" % orig)
- yield script
- downrev = script.down_revision
- script = self._revision_map[downrev]
-
- def _upgrade_revs(self, destination, current_rev):
- revs = self.iterate_revisions(destination, current_rev)
- return [
- (script.module.upgrade, script.down_revision, script.revision,
- script.doc)
- for script in reversed(list(revs))
- ]
-
- def _downgrade_revs(self, destination, current_rev):
- revs = self.iterate_revisions(current_rev, destination)
- return [
- (script.module.downgrade, script.revision, script.down_revision,
- script.doc)
- for script in revs
- ]
-
- def run_env(self):
- """Run the script environment.
-
- This basically runs the ``env.py`` script present
- in the migration environment. It is called exclusively
- by the command functions in :mod:`alembic.command`.
+ .. seealso::
+ :meth:`.RevisionMap.iterate_revisions`
"""
- util.load_python_file(self.dir, 'env.py')
-
- @property
- def env_py_location(self):
- return os.path.abspath(os.path.join(self.dir, "env.py"))
-
- @util.memoized_property
- def _revision_map(self):
- map_ = {}
-
- for file_ in os.listdir(self.versions):
- script = Script._from_filename(self, self.versions, file_)
- if script is None:
- continue
- if script.revision in map_:
- util.warn("Revision %s is present more than once" %
- script.revision)
- map_[script.revision] = script
- for rev in map_.values():
- if rev.down_revision is None:
- continue
- if rev.down_revision not in map_:
- util.warn("Revision %s referenced from %s is not present"
- % (rev.down_revision, rev))
- rev.down_revision = None
- else:
- map_[rev.down_revision].add_nextrev(rev.revision)
- map_[None] = None
- return map_
-
- def _rev_path(self, rev_id, message, create_date):
- slug = "_".join(_slug_re.findall(message or "")).lower()
- if len(slug) > self.truncate_slug_length:
- slug = slug[:self.truncate_slug_length].rsplit('_', 1)[0] + '_'
- filename = "%s.py" % (
- self.file_template % {
- 'rev': rev_id,
- 'slug': slug,
- 'year': create_date.year,
- 'month': create_date.month,
- 'day': create_date.day,
- 'hour': create_date.hour,
- 'minute': create_date.minute,
- 'second': create_date.second
- }
- )
- return os.path.join(self.versions, filename)
+ return self.revision_map.iterate_revisions(upper, lower)
def get_current_head(self):
"""Return the current head revision.
If the script directory has multiple heads
- due to branching, an error is raised.
+ due to branching, an error is raised;
+ :meth:`.ScriptDirectory.get_heads` should be
+ preferred.
- Returns a string revision number.
+ :return: a string revision number.
- """
- current_heads = self.get_heads()
- if len(current_heads) > 1:
- raise util.CommandError(
- 'Only a single head is supported. The '
- 'script directory has multiple heads (due to branching), '
- 'which must be resolved by manually editing the revision '
- 'files to form a linear sequence. Run `alembic branches` to '
- 'see the divergence(s).')
-
- if current_heads:
- return current_heads[0]
- else:
- return None
+ .. seealso::
+
+ :meth:`.ScriptDirectory.get_heads`
- _current_head = get_current_head
- """the 0.2 name, for backwards compat."""
+ """
+ with self._catch_revision_errors(multiple_heads=(
+ 'The script directory has multiple heads (due to branching).'
+ 'Please use get_heads(), or merge the branches using '
+ 'alembic merge.'
+ )):
+ return self.revision_map.get_current_head()
def get_heads(self):
"""Return all "head" revisions as strings.
- Returns a list of string revision numbers.
-
This is normally a list of length one,
unless branches are present. The
:meth:`.ScriptDirectory.get_current_head()` method
can be used normally when a script directory
has only one head.
+ :return: a tuple of string revision numbers.
"""
- heads = []
- for script in self._revision_map.values():
- if script and script.is_head:
- heads.append(script.revision)
- return heads
+ return list(self.revision_map.heads)
def get_base(self):
"""Return the "base" revision as a string.
@@ -305,17 +235,123 @@ class ScriptDirectory(object):
This is the revision number of the script that
has a ``down_revision`` of None.
- Behavior is not defined if more than one script
- has a ``down_revision`` of None.
+ If the script directory has multiple bases, an error is raised;
+ :meth:`.ScriptDirectory.get_bases` should be
+ preferred.
"""
- for script in self._revision_map.values():
- if script and script.down_revision is None \
- and script.revision in self._revision_map:
- return script.revision
+ bases = self.get_bases()
+ if len(bases) > 1:
+ raise util.CommandError(
+ "The script directory has multiple bases. "
+ "Please use get_bases().")
+ elif bases:
+ return bases[0]
else:
return None
+ def get_bases(self):
+ """return all "base" revisions as strings.
+
+ This is the revision number of all scripts that
+ have a ``down_revision`` of None.
+
+ .. versionadded:: 0.7.0
+
+ """
+ return list(self.revision_map.bases)
+
+ def _upgrade_revs(self, destination, current_rev):
+ with self._catch_revision_errors(
+ ancestor="Destination %(end)s is not a valid upgrade "
+ "target from current head(s)", end=destination):
+ revs = self.revision_map.iterate_revisions(
+ destination, current_rev, implicit_base=True)
+ revs = list(revs)
+ return [
+ migration.MigrationStep.upgrade_from_script(
+ self.revision_map, script)
+ for script in reversed(list(revs))
+ ]
+
+ def _downgrade_revs(self, destination, current_rev):
+ with self._catch_revision_errors(
+ ancestor="Destination %(end)s is not a valid downgrade "
+ "target from current head(s)", end=destination):
+ revs = self.revision_map.iterate_revisions(
+ current_rev, destination)
+ return [
+ migration.MigrationStep.downgrade_from_script(
+ self.revision_map, script)
+ for script in revs
+ ]
+
+ def _stamp_revs(self, revision, heads):
+ with self._catch_revision_errors(
+ multiple_heads="Multiple heads are present; please specify a "
+ "single target revision"):
+
+ heads = self.get_revisions(heads)
+
+ # filter for lineage will resolve things like
+ # branchname@base, version@base, etc.
+ filtered_heads = self.revision_map.filter_for_lineage(
+ heads, revision)
+
+ dest = self.get_revision(revision)
+
+ if dest is None:
+ # dest is 'base'. Return a "delete branch" migration
+ # for all applicable heads.
+ return [
+ migration.StampStep(head.revision, None, False, True)
+ for head in filtered_heads
+ ]
+ elif dest in filtered_heads:
+ # the dest is already in the version table, do nothing.
+ return []
+
+ # figure out if the dest is a descendant or an
+ # ancestor of the selected nodes
+ descendants = set(self.revision_map._get_descendant_nodes([dest]))
+ ancestors = set(self.revision_map._get_ancestor_nodes([dest]))
+
+ if descendants.intersection(filtered_heads):
+ # heads are above the target, so this is a downgrade.
+ # we can treat them as a "merge", single step.
+ assert not ancestors.intersection(filtered_heads)
+ todo_heads = [head.revision for head in filtered_heads]
+ step = migration.StampStep(
+ todo_heads, dest.revision, False, False)
+ return [step]
+ elif ancestors.intersection(filtered_heads):
+ # heads are below the target, so this is an upgrade.
+ # we can treat them as a "merge", single step.
+ todo_heads = [head.revision for head in filtered_heads]
+ step = migration.StampStep(
+ todo_heads, dest.revision, True, False)
+ return [step]
+ else:
+ # destination is in a branch not represented,
+ # treat it as new branch
+ step = migration.StampStep((), dest.revision, True, True)
+ return [step]
+
+ def run_env(self):
+ """Run the script environment.
+
+ This basically runs the ``env.py`` script present
+ in the migration environment. It is called exclusively
+ by the command functions in :mod:`alembic.command`.
+
+
+ """
+ util.load_python_file(self.dir, 'env.py')
+
+ @property
+ def env_py_location(self):
+ return os.path.abspath(os.path.join(self.dir, "env.py"))
+
def _generate_template(self, src, dest, **kw):
util.status("Generating %s" % os.path.abspath(dest),
util.template_to_file,
@@ -330,7 +366,9 @@ class ScriptDirectory(object):
shutil.copy,
src, dest)
- def generate_revision(self, revid, message, refresh=False, **kw):
+ def generate_revision(
+ self, revid, message, head=None,
+ refresh=False, splice=False, branch_labels=None, **kw):
"""Generate a new revision file.
This runs the ``script.py.mako`` template, given
@@ -340,6 +378,12 @@ class ScriptDirectory(object):
comes from ``alembic.util.rev_id()``.
:param message: the revision message, the one passed
by the -m argument to the ``revision`` command.
+ :param head: the head revision to generate against. Defaults
+ to the current "head" if no branches are present, else raises
+ an exception.
+
+ .. versionadded:: 0.7.0
+
:param refresh: when True, the in-memory state of this
:class:`.ScriptDirectory` will be updated with a new
:class:`.Script` instance representing the new revision;
@@ -347,32 +391,83 @@ class ScriptDirectory(object):
If False, the file is created but the state of the
:class:`.ScriptDirectory` is unmodified; ``None``
is returned.
+ :param splice: if True, allow the "head" version to not be an
+ actual head; otherwise, the selected head must be a head
+ (e.g. endpoint) revision.
"""
- current_head = self.get_current_head()
+ if head is None:
+ head = "head"
+
+ with self._catch_revision_errors(multiple_heads=(
+ "Multiple heads are present; please specify the head "
+ "revision on which the new revision should be based, "
+ "or perform a merge."
+ )):
+ heads = self.revision_map.get_revisions(head)
+
+ if len(set(heads)) != len(heads):
+ raise util.CommandError("Duplicate head revisions specified")
+
create_date = datetime.datetime.now()
path = self._rev_path(revid, message, create_date)
+
+ if not splice:
+ for head in heads:
+ if head is not None and not head.is_head:
+ raise util.CommandError(
+ "Revision %s is not a head revision; please specify "
+ "--splice to create a new branch from this revision"
+ % head.revision)
+
self._generate_template(
os.path.join(self.dir, "script.py.mako"),
path,
up_revision=str(revid),
- down_revision=current_head,
+ down_revision=revision.tuple_rev_as_scalar(
+ tuple(h.revision if h is not None else None for h in heads)),
+ branch_labels=util.to_tuple(branch_labels),
create_date=create_date,
+ comma=util.format_as_comma,
message=message if message is not None else ("empty message"),
**kw
)
if refresh:
script = Script._from_path(self, path)
- self._revision_map[script.revision] = script
- if script.down_revision:
- self._revision_map[script.down_revision].\
- add_nextrev(script.revision)
+ if branch_labels and not script.branch_labels:
+ raise util.CommandError(
+ "Version %s specified branch_labels %s, however the "
+ "migration file %s does not have them; have you upgraded "
+ "your script.py.mako to include the "
+ "'branch_labels' section?" % (
+ script.revision, branch_labels, script.path
+ ))
+
+ self.revision_map.add_revision(script)
return script
else:
return None
+ def _rev_path(self, rev_id, message, create_date):
+ slug = "_".join(_slug_re.findall(message or "")).lower()
+ if len(slug) > self.truncate_slug_length:
+ slug = slug[:self.truncate_slug_length].rsplit('_', 1)[0] + '_'
+ filename = "%s.py" % (
+ self.file_template % {
+ 'rev': rev_id,
+ 'slug': slug,
+ 'year': create_date.year,
+ 'month': create_date.month,
+ 'day': create_date.day,
+ 'hour': create_date.hour,
+ 'minute': create_date.minute,
+ 'second': create_date.second
+ }
+ )
+ return os.path.join(self.versions, filename)
+
-class Script(object):
+class Script(revision.Revision):
"""Represent a single revision file in a ``versions/`` directory.
@@ -381,16 +476,14 @@ class Script(object):
"""
- nextrev = frozenset()
-
def __init__(self, module, rev_id, path):
self.module = module
- self.revision = rev_id
self.path = path
- self.down_revision = getattr(module, 'down_revision', None)
-
- revision = None
- """The string revision number for this :class:`.Script` instance."""
+ super(Script, self).__init__(
+ rev_id,
+ module.down_revision,
+ branch_labels=util.to_tuple(
+ getattr(module, 'branch_labels', None), default=()))
module = None
"""The Python module representing the actual script itself."""
@@ -398,9 +491,6 @@ class Script(object):
path = None
"""Filesystem path of the script."""
- down_revision = None
- """The ``down_revision`` identifier within the migration script."""
-
@property
def doc(self):
"""Return the docstring given in the script."""
@@ -419,58 +509,83 @@ class Script(object):
else:
return ""
- def add_nextrev(self, rev):
- self.nextrev = self.nextrev.union([rev])
-
@property
- def is_head(self):
- """Return True if this :class:`.Script` is a 'head' revision.
-
- This is determined based on whether any other :class:`.Script`
- within the :class:`.ScriptDirectory` refers to this
- :class:`.Script`. Multiple heads can be present.
-
- """
- return not bool(self.nextrev)
+ def log_entry(self):
+ entry = "Rev: %s%s%s%s\n" % (
+ self.revision,
+ " (head)" if self.is_head else "",
+ " (branchpoint)" if self.is_branch_point else "",
+ " (mergepoint)" if self.is_merge_point else "",
+ )
+ if self.is_merge_point:
+ entry += "Merges: %s\n" % (self._format_down_revision(), )
+ else:
+ entry += "Parent: %s\n" % (self._format_down_revision(), )
- @property
- def is_branch_point(self):
- """Return True if this :class:`.Script` is a branch point.
+ if self.is_branch_point:
+ entry += "Branches into: %s\n" % (
+ util.format_as_comma(self.nextrev))
- A branchpoint is defined as a :class:`.Script` which is referred
- to by more than one succeeding :class:`.Script`, that is more
- than one :class:`.Script` has a `down_revision` identifier pointing
- here.
+ if self.branch_labels:
+ entry += "Branch names: %s\n" % (
+ util.format_as_comma(self.branch_labels), )
- """
- return len(self.nextrev) > 1
+ entry += "Path: %s\n" % (self.path,)
- @property
- def log_entry(self):
- return \
- "Rev: %s%s%s\n" \
- "Parent: %s\n" \
- "Path: %s\n" \
- "\n%s\n" % (
- self.revision,
- " (head)" if self.is_head else "",
- " (branchpoint)" if self.is_branch_point else "",
- self.down_revision,
- self.path,
- "\n".join(
- " %s" % para
- for para in self.longdoc.splitlines()
- )
+ entry += "\n%s\n" % (
+ "\n".join(
+ " %s" % para
+ for para in self.longdoc.splitlines()
)
+ )
+ return entry
def __str__(self):
- return "%s -> %s%s%s, %s" % (
- self.down_revision,
+ return "%s -> %s%s%s%s, %s" % (
+ self._format_down_revision(),
self.revision,
" (head)" if self.is_head else "",
" (branchpoint)" if self.is_branch_point else "",
+ " (mergepoint)" if self.is_merge_point else "",
self.doc)
+ def _head_only(
+ self, include_branches=False, include_doc=False,
+ include_parents=False, tree_indicators=True):
+ text = self.revision
+ if include_parents:
+ text = "%s -> %s" % (
+ self._format_down_revision(), text)
+ if include_branches and self.branch_labels:
+ text += " (%s)" % util.format_as_comma(self.branch_labels)
+ if tree_indicators:
+ text += "%s%s%s" % (
+ " (head)" if self.is_head else "",
+ " (branchpoint)" if self.is_branch_point else "",
+ " (mergepoint)" if self.is_merge_point else "",
+ )
+ if include_doc:
+ text += ", %s" % self.doc
+ return text
+
+ def cmd_format(
+ self,
+ verbose,
+ include_branches=False, include_doc=False,
+ include_parents=False, tree_indicators=True):
+ if verbose:
+ return self.log_entry
+ else:
+ return self._head_only(
+ include_branches, include_doc,
+ include_parents, tree_indicators)
+
+ def _format_down_revision(self):
+ if not self.down_revision:
+ return "<base>"
+ else:
+ return util.format_as_comma(self._down_revision_tuple)
+
@classmethod
def _from_path(cls, scriptdir, path):
dir_, filename = os.path.split(path)
diff --git a/alembic/templates/generic/script.py.mako b/alembic/templates/generic/script.py.mako
index 9570201..59606db 100644
--- a/alembic/templates/generic/script.py.mako
+++ b/alembic/templates/generic/script.py.mako
@@ -1,7 +1,7 @@
"""${message}
Revision ID: ${up_revision}
-Revises: ${down_revision}
+Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
@@ -9,6 +9,7 @@ Create Date: ${create_date}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
from alembic import op
import sqlalchemy as sa
diff --git a/alembic/templates/multidb/script.py.mako b/alembic/templates/multidb/script.py.mako
index a638b2d..fb923dc 100644
--- a/alembic/templates/multidb/script.py.mako
+++ b/alembic/templates/multidb/script.py.mako
@@ -4,7 +4,7 @@ import re
%>"""${message}
Revision ID: ${up_revision}
-Revises: ${down_revision}
+Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
@@ -12,6 +12,7 @@ Create Date: ${create_date}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
from alembic import op
import sqlalchemy as sa
diff --git a/alembic/templates/pylons/script.py.mako b/alembic/templates/pylons/script.py.mako
index 9570201..59606db 100644
--- a/alembic/templates/pylons/script.py.mako
+++ b/alembic/templates/pylons/script.py.mako
@@ -1,7 +1,7 @@
"""${message}
Revision ID: ${up_revision}
-Revises: ${down_revision}
+Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
@@ -9,6 +9,7 @@ Create Date: ${create_date}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
from alembic import op
import sqlalchemy as sa
diff --git a/alembic/testing/env.py b/alembic/testing/env.py
index 6c449a5..24707b4 100644
--- a/alembic/testing/env.py
+++ b/alembic/testing/env.py
@@ -166,7 +166,7 @@ def _testing_config():
def write_script(
scriptdir, rev_id, content, encoding='ascii', sourceless=False):
- old = scriptdir._revision_map[rev_id]
+ old = scriptdir.revision_map.get_revision(rev_id)
path = old.path
content = textwrap.dedent(content)
@@ -178,12 +178,11 @@ def write_script(
if os.access(pyc_path, os.F_OK):
os.unlink(pyc_path)
script = Script._from_path(scriptdir, path)
- old = scriptdir._revision_map[script.revision]
+ old = scriptdir.revision_map.get_revision(script.revision)
if old.down_revision != script.down_revision:
raise Exception("Can't change down_revision "
"on a refresh operation.")
- scriptdir._revision_map[script.revision] = script
- script.nextrev = old.nextrev
+ scriptdir.revision_map.add_revision(script, _replace=True)
if sourceless:
make_sourceless(path)
diff --git a/alembic/util.py b/alembic/util.py
index d261d09..2f8e7c4 100644
--- a/alembic/util.py
+++ b/alembic/util.py
@@ -5,13 +5,14 @@ import warnings
import re
import inspect
import uuid
+import collections
from mako.template import Template
from sqlalchemy.engine import url
from sqlalchemy import __version__
from .compat import callable, exec_, load_module_py, load_module_pyc, \
- binary_type
+ binary_type, string_types, py27
class CommandError(Exception):
@@ -44,6 +45,11 @@ from sqlalchemy.util.compat import inspect_getfullargspec
import logging
log = logging.getLogger(__name__)
+if py27:
+ # disable "no handler found" errors
+ logging.getLogger('alembic').addHandler(logging.NullHandler())
+
+
try:
import fcntl
import termios
@@ -282,6 +288,28 @@ def rev_id():
return hex(val)[2:-1]
+def to_tuple(x, default=None):
+ if x is None:
+ return default
+ elif isinstance(x, string_types):
+ return (x, )
+ elif isinstance(x, collections.Iterable):
+ return tuple(x)
+ else:
+ raise ValueError("Don't know how to turn %r into a tuple" % x)
+
+
+def format_as_comma(value):
+ if value is None:
+ return ""
+ elif isinstance(value, string_types):
+ return value
+ elif isinstance(value, collections.Iterable):
+ return ", ".join(value)
+ else:
+ raise ValueError("Don't know how to comma-format %r" % value)
+
+
class memoized_property(object):
"""A read-only @property that is only evaluated once."""
diff --git a/docs/build/api.rst b/docs/build/api.rst
index de8f37e..48da805 100644
--- a/docs/build/api.rst
+++ b/docs/build/api.rst
@@ -17,16 +17,16 @@ and :class:`.Operations` classes, pictured below.
An Alembic command begins by instantiating an :class:`.EnvironmentContext` object, then
making it available via the ``alembic.context`` proxy module. The ``env.py``
-script, representing a user-configurable migration environment, is then
+script, representing a user-configurable migration environment, is then
invoked. The ``env.py`` script is then responsible for calling upon the
-:meth:`.EnvironmentContext.configure`, whose job it is to create
-a :class:`.MigrationContext` object.
+:meth:`.EnvironmentContext.configure`, whose job it is to create
+a :class:`.MigrationContext` object.
Before this method is called, there's not
-yet any database connection or dialect-specific state set up. While
+yet any database connection or dialect-specific state set up. While
many methods on :class:`.EnvironmentContext` are usable at this stage,
-those which require database access, or at least access to the kind
-of database dialect in use, are not. Once the
+those which require database access, or at least access to the kind
+of database dialect in use, are not. Once the
:meth:`.EnvironmentContext.configure` method is called, the :class:`.EnvironmentContext`
is said to be *configured* with database connectivity, available via
a new :class:`.MigrationContext` object. The :class:`.MigrationContext`
@@ -37,7 +37,7 @@ Finally, ``env.py`` calls upon the :meth:`.EnvironmentContext.run_migrations`
method. Within this method, a new :class:`.Operations` object, which
provides an API for individual database migration operations, is established
within the ``alembic.op`` proxy module. The :class:`.Operations` object
-uses the :class:`.MigrationContext` object ultimately as a source of
+uses the :class:`.MigrationContext` object ultimately as a source of
database connectivity, though in such a way that it does not care if the
:class:`.MigrationContext` is talking to a real database or just writing
out SQL to a file.
@@ -46,7 +46,7 @@ The Environment Context
=======================
The :class:`.EnvironmentContext` class provides most of the
-API used within an ``env.py`` script. Within ``env.py``,
+API used within an ``env.py`` script. Within ``env.py``,
the instantated :class:`.EnvironmentContext` is made available
via a special *proxy module* called ``alembic.context``. That is,
you can import ``alembic.context`` like a regular Python module,
@@ -79,7 +79,7 @@ Alembic commands are all represented by functions in the :mod:`alembic.command`
package. They all accept the same style of usage, being sent
the :class:`~.alembic.config.Config` object as the first argument.
-Commands can be run programmatically, by first constructing a :class:`.Config`
+Commands can be run programmatically, by first constructing a :class:`.Config`
object, as in::
from alembic.config import Config
@@ -100,13 +100,13 @@ classes directly.
Configuration
==============
-The :class:`.Config` object represents the configuration
+The :class:`.Config` object represents the configuration
passed to the Alembic environment. From an API usage perspective,
it is needed for the following use cases:
* to create a :class:`.ScriptDirectory`, which allows you to work
with the actual script files in a migration environment
-* to create an :class:`.EnvironmentContext`, which allows you to
+* to create an :class:`.EnvironmentContext`, which allows you to
actually run the ``env.py`` module within the migration environment
* to programatically run any of the commands in the :mod:`alembic.command`
module.
@@ -132,6 +132,15 @@ to the Alembic version files present in the filesystem.
.. automodule:: alembic.script
:members:
+Revision
+========
+
+The :class:`.RevisionMap` object serves as the basis for revision
+management, used exclusively by :class:`.ScriptDirectory`.
+
+.. automodule:: alembic.revision
+ :members:
+
Autogeneration
==============
diff --git a/docs/build/changelog.rst b/docs/build/changelog.rst
index 74a9fa2..9001333 100644
--- a/docs/build/changelog.rst
+++ b/docs/build/changelog.rst
@@ -1,4 +1,5 @@
+
==========
Changelog
==========
@@ -6,6 +7,47 @@ Changelog
:version: 0.7.0
.. change::
+ :tags: feature, versioning
+ :tickets: 167
+
+ The "multiple heads / branches" feature has now landed. This is
+ by far the most significant change Alembic has seen since its inception;
+ while the workflow of most commands hasn't changed, and the format
+ of version files and the ``alembic_version`` table are unchanged as well,
+ a new suite of features opens up in the case where multiple version
+ files refer to the same parent, or to the "base". Merging of
+ branches, operating across distinct named heads, and multiple
+ independent bases are now all supported. The feature incurs radical
+ changes to the internals of versioning and traversal, and should be
+ treated as "beta mode" for the next several subsequent releases
+ within 0.7.
+
+ .. seealso::
+
+ :ref:`branches`
+
+ .. change::
+ :tags: feature, commands
+
+ New commands added: ``alembic show``, ``alembic heads`` and
+ ``alembic merge``. Also, a new option ``--verbose`` has been
+ added to several informational commands, such as ``alembic history``,
+ ``alembic current``, ``alembic branches``, and ``alembic heads``.
+ ``alembic revision`` also contains several new options used
+ within the new branch management system. The output of commands has
+ been altered in many cases to support new fields and attributes;
+ the ``history`` command in particular now returns it's "verbose" output
+ only if ``--verbose`` is sent; without this flag it reverts to it's
+ older behavior of short line items (which was never changed in the docs).
+
+ .. change::
+ :tags: changed, commands
+
+ The ``--head_only`` option to the ``alembic current`` command is
+ deprecated; the ``current`` command now lists just the version numbers
+ alone by default; use ``--verbose`` to get at additional output.
+
+ .. change::
:tags: feature, config
:pullreq: bitbucket:33
diff --git a/docs/build/tutorial.rst b/docs/build/tutorial.rst
index 016e81b..84297f1 100644
--- a/docs/build/tutorial.rst
+++ b/docs/build/tutorial.rst
@@ -207,8 +207,6 @@ This file contains the following features:
``%%(minute).2d``, ``%%(second).2d`` - components of the create date
as returned by ``datetime.datetime.now()``
- .. versionadded:: 0.3.6 - added date parameters to ``file_template``.
-
* ``truncate_slug_length`` - defaults to 40, the max number of characters
to include in the "slug" field.
@@ -223,8 +221,6 @@ This file contains the following features:
that the migration environment script ``env.py`` should be run unconditionally when
generating new revision files
- .. versionadded:: 0.3.3
-
* ``sourceless`` - when set to 'true', revision files that only exist as .pyc
or .pyo files in the versions directory will be used as versions, allowing
"sourceless" versioning folders. When left at the default of 'false',
@@ -249,6 +245,9 @@ the SQLAlchemy URL is all that's needed::
sqlalchemy.url = postgresql://scott:tiger@localhost/test
+
+.. _create_migration:
+
Create a Migration Script
=========================
@@ -263,7 +262,7 @@ A new file ``1975ea83b712_create_account_table.py`` is generated. Looking insid
"""create account table
Revision ID: 1975ea83b712
- Revises: None
+ Revises:
Create Date: 2011-11-08 11:40:27.089406
"""
@@ -271,6 +270,7 @@ A new file ``1975ea83b712_create_account_table.py`` is generated. Looking insid
# revision identifiers, used by Alembic.
revision = '1975ea83b712'
down_revision = None
+ branch_labels = None
from alembic import op
import sqlalchemy as sa
@@ -359,7 +359,7 @@ Let's do another one so we have some things to play with. We again create a r
file::
$ alembic revision -m "Add a column"
- Generating /path/to/yourapp/alembic/versions/ae1027a6acf.py_add_a_column.py...
+ Generating /path/to/yourapp/alembic/versions/ae1027a6acf_add_a_column.py...
done
Let's edit this file and add a new column to the ``account`` table::
@@ -397,7 +397,8 @@ We've now added the ``last_transaction_date`` column to the database.
Relative Migration Identifiers
==============================
-As of 0.3.3, relative upgrades/downgrades are also supported. To move two versions from the current, a decimal value "+N" can be supplied::
+Relative upgrades/downgrades are also supported. To move two versions from
+the current, a decimal value "+N" can be supplied::
$ alembic upgrade +2
@@ -405,6 +406,20 @@ Negative values are accepted for downgrades::
$ alembic downgrade -1
+Partial Revision Identifiers
+=============================
+
+Any time we need to refer to a revision number explicitly, we have the option
+to use a partial number. As long as this number uniquely identifies the
+version, it may be used in any command in any place that version numbers
+are accepted::
+
+ $ alembic upgrade ae1
+
+Above, we use ``ae1`` to refer to revision ``ae1027a6acf``.
+Alembic will stop and let you know if more than one version starts with
+that prefix.
+
Getting Information
===================
@@ -419,28 +434,40 @@ First we can view the current revision::
``head`` is displayed only if the revision identifier for this database matches the head revision.
-We can also view history::
+We can also view history with ``alembic history``; the ``--verbose`` option
+(accepted by several commands, including ``history``, ``current``, ``heads``
+and ``branches``) will show us full information about each revision::
- $ alembic history
+ $ alembic history --verbose
- 1975ea83b712 -> ae1027a6acf (head), Add a column
- None -> 1975ea83b712, empty message
+ Rev: ae1027a6acf (head)
+ Parent: 1975ea83b712
+ Path: /path/to/yourproject/alembic/versions/ae1027a6acf_add_a_column.py
-We can also identify specific migrations using just enough characters to uniquely identify them.
-If we wanted to upgrade directly to ``ae1027a6acf`` we could say::
+ add a column
- $ alembic upgrade ae1
+ Revision ID: ae1027a6acf
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:02:54.849677
+
+ Rev: 1975ea83b712
+ Parent: <base>
+ Path: /path/to/yourproject/alembic/versions/1975ea83b712_add_account_table.py
+
+ add account table
-Alembic will stop and let you know if more than one version starts with that prefix.
+ Revision ID: 1975ea83b712
+ Revises:
+ Create Date: 2014-11-20 13:02:46.257104
Viewing History Ranges
----------------------
Using the ``-r`` option to ``alembic history``, we can also view various slices
of history. The ``-r`` argument accepts an argument ``[start]:[end]``, where
-either may be a revision number, or various combinations of ``base``, ``head``,
-``currrent`` to specify the current revision, as well as negative relative
-ranges for ``[start]`` and positive relative ranges for ``[end]``::
+either may be a revision number, symbols like ``head``, ``heads`` or
+``base``, ``current`` to specify the current revision(s), as well as negative
+relative ranges for ``[start]`` and positive relative ranges for ``[end]``::
$ alembic history -r1975ea:ae1027
@@ -1333,20 +1360,53 @@ tokenized::
For more detail on the naming convention feature, see :ref:`sqla:constraint_naming_conventions`.
+.. _branches:
Working with Branches
=====================
-A *branch* describes when a source tree is broken up into two versions representing
-two independent sets of changes. The challenge of a branch is to *merge* the
-branches into a single series of changes. Alembic's GUID-based version number scheme
-allows branches to be reconciled.
+.. note:: Alembic 0.7.0 features an all-new versioning model that fully
+ supports branch points, merge points, and long-lived, labeled branches,
+ including independent branches originating from multiple bases.
+ A great emphasis has been placed on there being almost no impact on the
+ existing Alembic workflow, including that all commands work pretty much
+ the same as they did before, the format of migration files doesn't require
+ any change (though there are some changes that are recommended),
+ and even the structure of the ``alembic_version``
+ table does not change at all. However, most alembic commands now offer
+ new features which will break out an Alembic environment into
+ "branch mode", where things become a lot more intricate. Working in
+ "branch mode" should be considered as a "beta" feature, with many new
+ paradigms and use cases still to be stress tested in the wild.
+ Please tread lightly!
-Consider if we merged into our source repository another branch which contained
-a revision for another table called ``shopping_cart``. This revision was made
+.. versionadded:: 0.7.0
+
+A **branch** describes a point in a migration stream when two or more
+versions refer to the same parent migration as their anscestor. Branches
+occur naturally when two divergent source trees, both containing Alembic
+revision files created independently within those source trees, are merged
+together into one. When this occurs, the challenge of a branch is to **merge** the
+branches into a single series of changes, so that databases established
+from either source tree individually can be upgraded to reference the merged
+result equally. Another scenario where branches are present are when we create them
+directly; either at some point in the migration stream we'd like different
+series of migrations to be managed independently (e.g. we create a tree),
+or we'd like separate migration streams for different features starting
+at the root (e.g. a *forest*). We'll illustrate all of these cases, starting
+with the most common which is a source-merge-originated branch that we'll
+merge.
+
+Starting with the "account table" example we began in :ref:`create_migration`,
+assume we have our basemost version ``1975ea83b712``, which leads into
+the second revision ``ae1027a6acf``, and the migration files for these
+two revisions are checked into our source repository.
+Consider if we merged into our source repository another code branch which contained
+a revision for another table called ``shopping_cart``. This revision was made
against our first Alembic revision, the one that generated ``account``. After
-loading the second source tree in, a new file ``27c6a30d7c24.py`` exists within
-our ``versions`` directory. Both it, as well as ``ae1027a6acf.py``, reference
+loading the second source tree in, a new file
+``27c6a30d7c24_add_accont_table.py`` exists within our ``versions`` directory.
+Both it, as well as ``ae1027a6acf_add_a_column.py``, reference
``1975ea83b712`` as the "downgrade" revision. To illustrate::
# main source tree:
@@ -1355,67 +1415,618 @@ our ``versions`` directory. Both it, as well as ``ae1027a6acf.py``, reference
# branched source tree
1975ea83b712 (add account table) -> 27c6a30d7c24 (add shopping cart table)
-So above we can see 1975ea83b712 is our *branch point*. The Alembic command ``branches``
-illustrates this fact::
+Above, we can see ``1975ea83b712`` is our **branch point**; two distinct versions
+both refer to it as its parent. The Alembic command ``branches`` illustrates
+this fact::
+
+ $ alembic branches --verbose
+ Rev: 1975ea83b712 (branchpoint)
+ Parent: <base>
+ Branches into: 27c6a30d7c24, ae1027a6acf
+ Path: foo/versions/1975ea83b712_add_account_table.py
+
+ add account table
- $ alembic branches
- None -> 1975ea83b712 (branchpoint), add account table
- -> 1975ea83b712 -> 27c6a30d7c24 (head), add shopping cart table
- -> 1975ea83b712 -> ae1027a6acf (head), add a column
+ Revision ID: 1975ea83b712
+ Revises:
+ Create Date: 2014-11-20 13:02:46.257104
+
+ -> 27c6a30d7c24 (head), add shopping cart table
+ -> ae1027a6acf (head), add a column
History shows it too, illustrating two ``head`` entries as well
as a ``branchpoint``::
$ alembic history
-
1975ea83b712 -> 27c6a30d7c24 (head), add shopping cart table
-
1975ea83b712 -> ae1027a6acf (head), add a column
- None -> 1975ea83b712 (branchpoint), add account table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+We can get a view of just the current heads using ``alembic heads``::
+
+ $ alembic heads --verbose
+ Rev: 27c6a30d7c24 (head)
+ Parent: 1975ea83b712
+ Path: foo/versions/27c6a30d7c24_add_shopping_cart_table.py
+
+ add shopping cart table
+
+ Revision ID: 27c6a30d7c24
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:03:11.436407
+
+ Rev: ae1027a6acf (head)
+ Parent: 1975ea83b712
+ Path: foo/versions/ae1027a6acf_add_a_column.py
+
+ add a column
+
+ Revision ID: ae1027a6acf
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:02:54.849677
-Alembic will also refuse to run any migrations until this is resolved::
+If we try to run an ``upgrade`` to the usual end target of ``head``, Alembic no
+longer considers this to be an unambiguous command. As we have more than
+one ``head``, the ``upgrade`` command wants us to provide more information::
$ alembic upgrade head
- INFO [alembic.context] Context class PostgresqlContext.
- INFO [alembic.context] Will assume transactional DDL.
- Exception: Only a single head supported so far...
+ FAILED: Multiple head revisions are present for given argument 'head'; please specify a specific
+ target revision, '<branchname>@head' to narrow to a specific head, or 'heads' for all heads
-We resolve this branch by editing the files to be in a straight line. In this case we edit
-``27c6a30d7c24.py`` to point to ``ae1027a6acf.py``::
+The ``upgrade`` command gives us quite a few options in which we can proceed
+with our upgrade, either giving it information on *which* head we'd like to upgrade
+towards, or alternatively stating that we'd like *all* heads to be upgraded
+towards at once. However, in the typical case of two source trees being
+merged, we will want to pursue a third option, which is that we can **merge** these
+branches.
- """add shopping cart table
+Merging Branches
+----------------
- Revision ID: 27c6a30d7c24
- Revises: ae1027a6acf # changed from 1975ea83b712
- Create Date: 2011-11-08 13:02:14.212810
+An Alembic merge is a migration file that joins two or
+more "head" files together. If the two branches we have right now can
+be said to be a "tree" structure, introducing this merge file will
+turn it into a "diamond" structure::
+
+ -- ae1027a6acf -->
+ / \
+ <base> --> 1975ea83b712 --> --> mergepoint
+ \ /
+ -- 27c6a30d7c24 -->
+
+We create the merge file using ``alembic merge``; with this command, we can
+pass to it an argument such as ``heads``, meaning we'd like to merge all
+heads. Or, we can pass it individual revision numbers sequentally::
+
+ $ alembic merge -m "merge ae1 and 27c" ae1027 27c6a
+ Generating /path/to/foo/versions/53fffde5ad5_merge_ae1_and_27c.py ... done
+
+Looking inside the new file, we see it as a regular migration file, with
+the only new twist is that ``down_revision`` points to both revisions::
+
+ """merge ae1 and 27c
+
+ Revision ID: 53fffde5ad5
+ Revises: ae1027a6acf, 27c6a30d7c24
+ Create Date: 2014-11-20 13:31:50.811663
+
+ """
+
+ # revision identifiers, used by Alembic.
+ revision = '53fffde5ad5'
+ down_revision = ('ae1027a6acf', '27c6a30d7c24')
+ branch_labels = None
+
+ from alembic import op
+ import sqlalchemy as sa
+
+
+ def upgrade():
+ pass
+
+
+ def downgrade():
+ pass
+
+This file is a regular migration file, and if we wish to, we may place
+:class:`.Operations` directives into the ``upgrade()`` and ``downgrade()``
+functions like any other migration file. Though it is probably best to limit
+the instructions placed here only to those that deal with any kind of
+reconciliation that is needed between the two merged branches, if any.
+
+The ``heads`` command now illustrates that the multiple heads in our
+``versions/`` directory have been resolved into our new head::
+
+ $ alembic heads --verbose
+ Rev: 53fffde5ad5 (head) (mergepoint)
+ Merges: ae1027a6acf, 27c6a30d7c24
+ Path: foo/versions/53fffde5ad5_merge_ae1_and_27c.py
+
+ merge ae1 and 27c
+
+ Revision ID: 53fffde5ad5
+ Revises: ae1027a6acf, 27c6a30d7c24
+ Create Date: 2014-11-20 13:31:50.811663
+
+History shows a similar result, as the mergepoint becomes our head::
+
+ $ alembic history
+ ae1027a6acf, 27c6a30d7c24 -> 53fffde5ad5 (head) (mergepoint), merge ae1 and 27c
+ 1975ea83b712 -> ae1027a6acf, add a column
+ 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+With a single ``head`` target, a generic ``upgrade`` can proceed::
+
+ $ alembic upgrade head
+ INFO [alembic.migration] Context impl PostgresqlImpl.
+ INFO [alembic.migration] Will assume transactional DDL.
+ INFO [alembic.migration] Running upgrade -> 1975ea83b712, add account table
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> ae1027a6acf, add a column
+ INFO [alembic.migration] Running upgrade ae1027a6acf, 27c6a30d7c24 -> 53fffde5ad5, merge ae1 and 27c
+
+
+.. topic:: merge mechanics
+
+ The upgrade process traverses through all of our migration files using
+ a **topological sorting** algorithm, treating the list of migration
+ files not as a linked list, but as a **directed acyclic graph**. The starting
+ points of this traversal are the **current heads** within our database,
+ and the end point is the "head" revision or revisions specified.
+
+ When a migration proceeds across a point at which there are multiple heads,
+ the ``alembic_version`` table will at that point store *multiple* rows,
+ one for each head. Our migration process above will emit SQL against
+ ``alembic_version`` along these lines:
+
+ .. sourcecode:: sql
+
+ -- Running upgrade -> 1975ea83b712, add account table
+ INSERT INTO alembic_version (version_num) VALUES ('1975ea83b712')
+
+ -- Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+ UPDATE alembic_version SET version_num='27c6a30d7c24' WHERE alembic_version.version_num = '1975ea83b712'
+
+ -- Running upgrade 1975ea83b712 -> ae1027a6acf, add a column
+ INSERT INTO alembic_version (version_num) VALUES ('ae1027a6acf')
+
+ -- Running upgrade ae1027a6acf, 27c6a30d7c24 -> 53fffde5ad5, merge ae1 and 27c
+ DELETE FROM alembic_version WHERE alembic_version.version_num = 'ae1027a6acf'
+ UPDATE alembic_version SET version_num='53fffde5ad5' WHERE alembic_version.version_num = '27c6a30d7c24'
+
+ At the point at which both ``27c6a30d7c24`` and ``ae1027a6acf`` exist within our
+ database, both values are present in ``alembic_version``, which now has
+ two rows. If we upgrade to these two versions alone, then stop and
+ run ``alembic current``, we will see this::
+
+ $ alembic current --verbose
+ Current revision(s) for postgresql://scott:XXXXX@localhost/test:
+ Rev: ae1027a6acf
+ Parent: 1975ea83b712
+ Path: foo/versions/ae1027a6acf_add_a_column.py
+
+ add a column
+
+ Revision ID: ae1027a6acf
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:02:54.849677
+
+ Rev: 27c6a30d7c24
+ Parent: 1975ea83b712
+ Path: foo/versions/27c6a30d7c24_add_shopping_cart_table.py
+
+ add shopping cart table
+
+ Revision ID: 27c6a30d7c24
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:03:11.436407
+
+ A key advantage to the ``merge`` process is that it will
+ run equally well on databases that were present on version ``ae1027a6acf``
+ alone, versus databases that were present on version ``27c6a30d7c24`` alone;
+ whichever version was not yet applied, will be applied before the merge point
+ can be crossed. This brings forth a way of thinking about a merge file,
+ as well as about any Alembic revision file. As they are considered to
+ be "nodes" within a set that is subject to topological sorting, each
+ "node" is a point that cannot be crossed until all of its dependencies
+ are satisfied.
+
+ Prior to Alembic's support of merge points, the use case of databases
+ sitting on different heads was basically impossible to reconcile; having
+ to manually splice the head files together invariably meant that one migration
+ would occur before the other, thus being incompatible with databases that
+ were present on the other migration.
+
+Working with Explicit Branches
+------------------------------
+
+The ``alembic upgrade`` command hinted at other options besides merging when
+dealing with multiple heads. Let's back up and assume we're back where
+we have as our heads just ``ae1027a6acf`` and ``27c6a30d7c24``::
+
+ $ alembic heads
+ 27c6a30d7c24
+ ae1027a6acf
+
+Earlier, when we did ``alembic upgrade head``, it gave us an error which
+suggested ``please specify a specific target revision, '<branchname>@head' to
+narrow to a specific head, or 'heads' for all heads`` in order to proceed
+without merging. Let's cover those cases.
+
+Referring to all heads at once
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The ``heads`` identifier is a lot like ``head``, except it explicitly refers
+to *all* heads at once. That is, it's like telling Alembic to do the operation
+for both ``ae1027a6acf`` and ``27c6a30d7c24`` simultaneously. If we started
+from a fresh database and ran ``upgrade heads`` we'd see::
+
+ $ alembic upgrade heads
+ INFO [alembic.migration] Context impl PostgresqlImpl.
+ INFO [alembic.migration] Will assume transactional DDL.
+ INFO [alembic.migration] Running upgrade -> 1975ea83b712, add account table
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> ae1027a6acf, add a column
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+
+Since we've upgraded to ``heads``, and we do in fact have more than one head,
+that means these two distinct heads are now in our ``alembic_version`` table.
+We can see this if we run ``alembic current``::
+
+ $ alembic current
+ ae1027a6acf (head)
+ 27c6a30d7c24 (head)
+
+That means there's two rows in ``alembic_version`` right now. If we downgrade
+one step at a time, Alembic will **delete** from the ``alembic_version`` table
+each branch that's closed out, until only one branch remains; then it will
+continue updating the single value down to the previous versions::
+
+ $ alembic downgrade -1
+ INFO [alembic.migration] Running downgrade ae1027a6acf -> 1975ea83b712, add a column
+
+ $ alembic current
+ 27c6a30d7c24 (head)
+
+ $ alembic downgrade -1
+ INFO [alembic.migration] Running downgrade 27c6a30d7c24 -> 1975ea83b712, add shopping cart table
+
+ $ alembic current
+ 1975ea83b712 (branchpoint)
+
+ $ alembic downgrade -1
+ INFO [alembic.migration] Running downgrade 1975ea83b712 -> , add account table
+
+ $ alembic current
+
+Referring to a Specific Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We can pass a specific version number to ``upgrade``. Alembic will ensure that
+all revisions upon which this version depends are invoked, and nothing more.
+So if we ``upgrade`` either to ``27c6a30d7c24`` or ``ae1027a6acf`` specifically,
+it guarantees that ``1975ea83b712`` will have been applied, but not that
+any "sibling" versions are applied::
+
+ $ alembic upgrade 27c6a
+ INFO [alembic.migration] Running upgrade -> 1975ea83b712, add account table
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+
+With ``1975ea83b712`` and ``27c6a30d7c24`` applied, ``ae1027a6acf`` is just
+a single additional step::
+
+ $ alembic upgrade ae102
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> ae1027a6acf, add a column
+
+Working with Branch Labels
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+To satisfy the use case where an environment has long-lived branches, especially
+independent branches as will be discussed in the next section, Alembic supports
+the concept of **branch labels**. These are string values that are present
+within the migration file, using the new identifier ``branch_labels``.
+For example, if we want to refer to the "shopping cart" branch using the name
+"shoppingcart", we can add that name to our file
+``27c6a30d7c24_add_shopping_cart_table.py``::
+
+ """add shopping cart table
"""
# revision identifiers, used by Alembic.
revision = '27c6a30d7c24'
- # changed from 1975ea83b712
- down_revision = 'ae1027a6acf'
+ down_revision = '1975ea83b712'
+ branch_labels = ('shoppingcart',)
+
+ # ...
+
+The ``branch_labels`` attribute refers to a string name, or a tuple
+of names, which will now apply to this revision, all descendants of this
+revision, as well as all ancestors of this revision up until the preceding
+branch point, in this case ``1975ea83b712``. We can see the ``shoppingcart``
+label applied to this revision::
+
+ $ alembic history
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart) (head), add shopping cart table
+ 1975ea83b712 -> ae1027a6acf (head), add a column
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+With the label applied, the name ``shoppingcart`` now serves as an alias
+for the ``27c6a30d7c24`` revision specifically. We can illustrate this
+by showing it with ``alembic show``::
+
+ $ alembic show shoppingcart
+ Rev: 27c6a30d7c24 (head)
+ Parent: 1975ea83b712
+ Branch names: shoppingcart
+ Path: foo/versions/27c6a30d7c24_add_shopping_cart_table.py
+
+ add shopping cart table
+
+ Revision ID: 27c6a30d7c24
+ Revises: 1975ea83b712
+ Create Date: 2014-11-20 13:03:11.436407
+
+However, when using branch labels, we usually want to use them using a syntax
+known as "branch at" syntax; this syntax allows us to state that we want to
+use a specific revision, let's say a "head" revision, in terms of a *specific*
+branch. While normally, we can't refer to ``alembic upgrade head`` when
+there's multiple heads, we *can* refer to this head specifcally using
+``shoppingcart@head`` syntax::
+
+ $ alembic upgrade shoppingcart@head
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+
+The ``shoppingcart@head`` syntax becomes important to us if we wish to
+add new migration files to our versions directory while maintaining multiple
+branches. Just like the ``upgrade`` command, if we attempted to add a new
+revision file to our multiple-heads layout without a specific parent revision,
+we'd get a familiar error::
+
+ $ alembic revision -m "add a shopping cart column"
+ FAILED: Multiple heads are present; please specify the head revision on
+ which the new revision should be based, or perform a merge.
+
+The ``alembic revision`` command is pretty clear in what we need to do;
+to add our new revision specifically to the ``shoppingcart`` branch,
+we use the ``--head`` argument, either with the specific revision identifier
+``27c6a30d7c24``, or more generically using our branchname ``shoppingcart@head``::
+
+ $ alembic revision -m "add a shopping cart column" --head shoppingcart@head
+ Generating /path/to/foo/versions/d747a8a8879_add_a_shopping_cart_column.py ... done
+
+``alembic history`` shows both files now part of the ``shoppingcart`` branch::
+
+ $ alembic history
+ 1975ea83b712 -> ae1027a6acf (head), add a column
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart) (head), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+We can limit our history operation just to this branch as well::
+
+ $ alembic history -r shoppingcart:
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart) (head), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+
+If we want to illustrate the path of ``shoppingcart`` all the way from the
+base, we can do that as follows::
+
+ $ alembic history -r :shoppingcart@head
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart) (head), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+We can run this operation from the "base" side as well, but we get a different
+result::
+
+ $ alembic history -r shoppingcart@base:
+ 1975ea83b712 -> ae1027a6acf (head), add a column
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart) (head), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+When we list from ``shoppingcart@base`` without an endpoint, it's really shorthand
+for ``-r shoppingcart@base:heads``, e.g. all heads, and since ``shoppingcart@base``
+is the same "base" shared by the ``ae1027a6acf`` revision, we get that
+revision in our listing as well. The ``<branchname>@base`` syntax can be
+useful when we are dealing with individual bases, as we'll see in the next
+section.
+
+The ``<branchname>@head`` format can also be used with revision numbers
+instead of branch names, though this is less convenient. If we wanted to
+add a new revision to our branch that includes the un-labeled ``ae1027a6acf``,
+if this weren't a head already, we could ask for the "head of the branch
+that includes ``ae1027a6acf``" as follows::
+
+ $ alembic revision -m "add another account column" --head ae10@head
+ Generating /Users/classic/dev/alembic/foo/versions/55af2cb1c267_add_another_account_column.py ... done
+
+
+Working with Multiple Bases
+---------------------------
+
+We've seen in the previous section that ``alembic upgrade`` is fine
+if we have multiple heads, ``alembic revision`` allows us to tell it which
+"head" we'd like to associate our new revision file with, and branch labels
+allow us to assign names to branches that we can use in subsequent commands.
+Let's put all these together and refer to a new "base", that is, a whole
+new tree of revision files that will be semi-independent of the account/shopping
+cart revisions we've been working with.
+
+Creating a Labeled Base Revision
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We want to create a new, labeled branch in one step. To ensure the branch can
+accommodate this label, we need to ensure our ``script.py.mako`` file, used
+for generating new revision files, has the appropriate substitutions present.
+If Alembic version 0.7.0 or greater was used to generate the original
+migration environment, this is already done. However when working with an older
+environment, ``script.py.mako`` needs to have this directive added, typically
+underneath the ``down_revision`` directive::
+
+ # revision identifiers, used by Alembic.
+ revision = ${repr(up_revision)}
+ down_revision = ${repr(down_revision)}
+
+ # add this here in order to use revision with branch_label
+ branch_labels = ${repr(branch_labels)}
+
+With this in place, we can create a new revision file, starting up a branch
+that will deal with database tables involving networking; we specify the
+"head" version of ``base`` as well as a ``branch_label``::
-.. sidebar:: The future of Branches
+ $ alembic revision -m "create networking branch" --head=base --branch-label=networking
+ Generating /Users/classic/dev/alembic/foo/versions/3782d9986ced_create_networking_branch.py ... done
- As of this writing, a new approach to branching has been planned. When
- implemented, the task of manually splicing files into a line will no longer
- be needed; instead, a simple command along the lines of ``alembic merge``
- will be able to produce merges of migration files. Keep a lookout
- for future Alembic versions!
+If we ran the above command and we didn't have the newer ``script.py.mako``
+directive, we'd get this error::
-The ``branches`` command then shows no branches::
+ FAILED: Version 3cac04ae8714 specified branch_labels networking, however
+ the migration file foo/versions/3cac04ae8714_create_networking_branch.py
+ does not have them; have you upgraded your script.py.mako to include the 'branch_labels'
+ section?
- $ alembic branches
- $
+When we receive the above error, and we would like to try again, we need to
+either **delete** the incorrectly generated file in order to run ``revision``
+again, *or* we can edit the ``3cac04ae8714_create_networking_branch.py``
+directly to add the ``branch_labels`` in of our choosing.
-And the history is similarly linear::
+Running with Multiple Bases
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Once we have a new, permanent (for as long as we desire it to be)
+base in our system, we'll always have multiple heads present::
+
+ $ alembic heads
+ 3782d9986ced (networking)
+ ae1027a6acf
+ d747a8a8879 (shoppingcart)
+
+When we want to add a new revision file to ``networking``, we specify
+``networking@head`` as the ``--head``::
+
+ $ alembic revision -m "add ip number table" --head=networking@head
+ Generating /Users/classic/dev/alembic/foo/versions/109ec7d132bf_add_ip_number_table.py ... done
+
+It's important that we refer to the head using ``networking@head``; if we
+only refer to ``networking``, that refers to only ``3782d9986ced`` specifically;
+if we specify this and it's not a head, ``alembic revision`` will make sure
+we didn't mean to specify the head::
+
+ $ alembic revision -m "add DNS table" --head=networking
+ FAILED: Revision 3782d9986ced is not a head revision; please
+ specify --splice to create a new branch from this revision
+
+As mentioned earlier, as this base is independent, we can view its history
+from the base using ``history -r networking@base:``::
+
+ $ alembic history -r networking@base:
+ 109ec7d132bf -> 29f859a13ea (networking) (head), add DNS table
+ 3782d9986ced -> 109ec7d132bf (networking), add ip number table
+ <base> -> 3782d9986ced (networking), create networking branch
+
+Note this is the same output we'd get at this point if we used
+``-r :networking@head``.
+
+
+We have quite a lot of versioning going on, history overall now shows::
$ alembic history
+ 109ec7d132bf -> 29f859a13ea (networking) (head), add DNS table
+ 3782d9986ced -> 109ec7d132bf (networking), add ip number table
+ <base> -> 3782d9986ced (networking), create networking branch
+ ae1027a6acf -> 55af2cb1c267 (head), add another account column
+ 1975ea83b712 -> ae1027a6acf, add a column
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart) (head), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+We may now run upgrades or downgrades freely, among individual branches
+(let's assume a clean database again)::
+
+ $ alembic upgrade networking@head
+ INFO [alembic.migration] Running upgrade -> 3782d9986ced, create networking branch
+ INFO [alembic.migration] Running upgrade 3782d9986ced -> 109ec7d132bf, add ip number table
+ INFO [alembic.migration] Running upgrade 109ec7d132bf -> 29f859a13ea, add DNS table
+
+or against the whole thing using ``heads``::
- ae1027a6acf -> 27c6a30d7c24 (head), add shopping cart table
+ $ alembic upgrade heads
+ INFO [alembic.migration] Running upgrade -> 1975ea83b712, add account table
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> 27c6a30d7c24, add shopping cart table
+ INFO [alembic.migration] Running upgrade 27c6a30d7c24 -> d747a8a8879, add a shopping cart column
+ INFO [alembic.migration] Running upgrade 1975ea83b712 -> ae1027a6acf, add a column
+ INFO [alembic.migration] Running upgrade ae1027a6acf -> 55af2cb1c267, add another account column
+
+If you actually wanted, all three branches can be merged::
+
+ $ alembic merge -m "merge all three branches" heads
+ Generating /Users/classic/dev/alembic/foo/versions/3180f4d6e81d_merge_all_three_branches.py ... done
+
+ $ alembic upgrade head
+ INFO [alembic.migration] Running upgrade 29f859a13ea, 55af2cb1c267, d747a8a8879 -> 3180f4d6e81d, merge all three branches
+
+at which point, we're back to one head, but note! This head has **two** labels
+now::
+
+ $ alembic heads
+ 3180f4d6e81d (shoppingcart, networking)
+
+ $ alembic current --verbose
+ Current revision(s) for postgresql://scott:XXXXX@localhost/test:
+ Rev: 3180f4d6e81d (head) (mergepoint)
+ Merges: 29f859a13ea, 55af2cb1c267, d747a8a8879
+ Branch names: shoppingcart, networking
+ Path: foo/versions/3180f4d6e81d_merge_all_three_branches.py
+
+ merge all three branches
+
+ Revision ID: 3180f4d6e81d
+ Revises: 29f859a13ea, 55af2cb1c267, d747a8a8879
+ Create Date: 2014-11-20 16:27:56.395477
+
+When labels are combined like this, it means that ``networking@head`` and
+``shoppingcart@head`` are ultimately along the same branch, as is the
+unnamed ``ae1027a6acf`` branch since we've merged everything together.
+``alembic history`` when leading from ``networking@base:``,
+``:shoppingcart@head`` or similar will show the whole tree at this point::
+
+ $ alembic history -r :shoppingcart@head
+ 29f859a13ea, 55af2cb1c267, d747a8a8879 -> 3180f4d6e81d (networking, shoppingcart) (head) (mergepoint), merge all three branches
+ 109ec7d132bf -> 29f859a13ea (networking), add DNS table
+ 3782d9986ced -> 109ec7d132bf (networking), add ip number table
+ <base> -> 3782d9986ced (networking), create networking branch
+ ae1027a6acf -> 55af2cb1c267, add another account column
1975ea83b712 -> ae1027a6acf, add a column
- None -> 1975ea83b712, add account table
+ 27c6a30d7c24 -> d747a8a8879 (shoppingcart), add a shopping cart column
+ 1975ea83b712 -> 27c6a30d7c24 (shoppingcart), add shopping cart table
+ <base> -> 1975ea83b712 (branchpoint), add account table
+
+It follows then that the "branch labels" feature is useful for branches
+that are **unmerged**. Once branches are merged into a single stream, labels
+are not particularly useful as they tend to refer to the whole revision
+stream in any case. They can of course be removed from revision files
+at the point at which they are no longer useful, or moved to other files.
+
+For posterity, here's the graph of the whole thing::
+
+ --- ae10 --> 55af --->--
+ / \
+ <base> --> 1975 --> |
+ \ |
+ --- 27c6 --> d747 --> |
+ (shoppingcart) \ |
+ +--+-----> 3180
+ | (networking,
+ / shoppingcart)
+ <base> --> 3782 -----> 109e ----> 29f8 --->
+ (networking)
+
+
+If there's any point to be made here, it's if you are too freely branching, merging
+and labeling, things can get pretty crazy! Hence the branching system should
+be used carefully and thoughtfully for best results.
.. _building_uptodate:
diff --git a/tests/test_command.py b/tests/test_command.py
index 58fed89..933b347 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -3,8 +3,10 @@ from io import TextIOWrapper, BytesIO
from alembic.script import ScriptDirectory
from alembic.testing.fixtures import TestBase, capture_context_buffer
from alembic.testing.env import staging_env, _sqlite_testing_config, \
- three_rev_fixture, clear_staging_env, _no_sql_testing_config
-from alembic.testing import eq_
+ three_rev_fixture, clear_staging_env, _no_sql_testing_config, \
+ _sqlite_file_db, write_script, env_file_fixture
+from alembic.testing import eq_, assert_raises_message
+from alembic import util
class HistoryTest(TestBase):
@@ -42,47 +44,195 @@ class HistoryTest(TestBase):
def test_history_full(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg)
+ command.history(self.cfg, verbose=True)
self._eq_cmd_output(buf, [self.c, self.b, self.a])
def test_history_num_range(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "%s:%s" % (self.a, self.b))
- self._eq_cmd_output(buf, [self.b])
+ command.history(self.cfg, "%s:%s" % (self.a, self.b), verbose=True)
+ self._eq_cmd_output(buf, [self.b, self.a])
def test_history_base_to_num(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, ":%s" % (self.b))
+ command.history(self.cfg, ":%s" % (self.b), verbose=True)
self._eq_cmd_output(buf, [self.b, self.a])
def test_history_num_to_head(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "%s:" % (self.a))
- self._eq_cmd_output(buf, [self.c, self.b])
+ command.history(self.cfg, "%s:" % (self.a), verbose=True)
+ self._eq_cmd_output(buf, [self.c, self.b, self.a])
def test_history_num_plus_relative(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "%s:+2" % (self.a))
- self._eq_cmd_output(buf, [self.c, self.b])
+ command.history(self.cfg, "%s:+2" % (self.a), verbose=True)
+ self._eq_cmd_output(buf, [self.c, self.b, self.a])
def test_history_relative_to_num(self):
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "-2:%s" % (self.c))
- self._eq_cmd_output(buf, [self.c, self.b])
+ command.history(self.cfg, "-2:%s" % (self.c), verbose=True)
+ self._eq_cmd_output(buf, [self.c, self.b, self.a])
+
+ def test_history_too_large_relative_to_num(self):
+ self.cfg.stdout = buf = self._buf_fixture()
+ command.history(self.cfg, "-5:%s" % (self.c), verbose=True)
+ self._eq_cmd_output(buf, [self.c, self.b, self.a])
def test_history_current_to_head_as_b(self):
command.stamp(self.cfg, self.b)
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "current:")
- self._eq_cmd_output(buf, [self.c])
+ command.history(self.cfg, "current:", verbose=True)
+ self._eq_cmd_output(buf, [self.c, self.b])
def test_history_current_to_head_as_base(self):
command.stamp(self.cfg, "base")
self.cfg.stdout = buf = self._buf_fixture()
- command.history(self.cfg, "current:")
+ command.history(self.cfg, "current:", verbose=True)
self._eq_cmd_output(buf, [self.c, self.b, self.a])
+class RevisionTest(TestBase):
+ def setUp(self):
+ self.env = staging_env()
+ self.cfg = _sqlite_testing_config()
+
+ def tearDown(self):
+ clear_staging_env()
+
+ def _env_fixture(self):
+ env_file_fixture("""
+
+from sqlalchemy import MetaData, engine_from_config
+target_metadata = MetaData()
+
+engine = engine_from_config(
+ config.get_section(config.config_ini_section),
+ prefix='sqlalchemy.')
+
+connection = engine.connect()
+
+context.configure(connection=connection, target_metadata=target_metadata)
+
+try:
+ with context.begin_transaction():
+ context.run_migrations()
+finally:
+ connection.close()
+
+""")
+
+ def test_create_rev_plain_db_not_up_to_date(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ command.revision(self.cfg) # no problem
+
+ def test_create_rev_autogen(self):
+ self._env_fixture()
+ command.revision(self.cfg, autogenerate=True)
+
+ def test_create_rev_autogen_db_not_up_to_date(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ assert_raises_message(
+ util.CommandError,
+ "Target database is not up to date.",
+ command.revision, self.cfg, autogenerate=True
+ )
+
+ def test_create_rev_autogen_db_not_up_to_date_multi_heads(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ rev3a = command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.upgrade(self.cfg, "heads")
+ command.revision(self.cfg, head=rev3a.revision)
+
+ assert_raises_message(
+ util.CommandError,
+ "Target database is not up to date.",
+ command.revision, self.cfg, autogenerate=True
+ )
+
+ def test_create_rev_plain_db_not_up_to_date_multi_heads(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ rev3a = command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.upgrade(self.cfg, "heads")
+ command.revision(self.cfg, head=rev3a.revision)
+
+ assert_raises_message(
+ util.CommandError,
+ "Multiple heads are present; please specify the head revision "
+ "on which the new revision should be based, or perform a merge.",
+ command.revision, self.cfg
+ )
+
+ def test_create_rev_autogen_need_to_select_head(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.upgrade(self.cfg, "heads")
+ # there's multiple heads present
+ assert_raises_message(
+ util.CommandError,
+ "Multiple heads are present; please specify the head revision "
+ "on which the new revision should be based, or perform a merge.",
+ command.revision, self.cfg, autogenerate=True
+ )
+
+ def test_create_rev_plain_need_to_select_head(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.upgrade(self.cfg, "heads")
+ # there's multiple heads present
+ assert_raises_message(
+ util.CommandError,
+ "Multiple heads are present; please specify the head revision "
+ "on which the new revision should be based, or perform a merge.",
+ command.revision, self.cfg
+ )
+
+ def test_create_rev_plain_post_merge(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.merge(self.cfg, "heads")
+ command.revision(self.cfg)
+
+ def test_create_rev_autogenerate_post_merge(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.merge(self.cfg, "heads")
+ command.upgrade(self.cfg, "heads")
+ command.revision(self.cfg, autogenerate=True)
+
+ def test_create_rev_autogenerate_db_not_up_to_date_post_merge(self):
+ self._env_fixture()
+ command.revision(self.cfg)
+ rev2 = command.revision(self.cfg)
+ command.revision(self.cfg)
+ command.revision(self.cfg, head=rev2.revision, splice=True)
+ command.upgrade(self.cfg, "heads")
+ command.merge(self.cfg, "heads")
+ assert_raises_message(
+ util.CommandError,
+ "Target database is not up to date.",
+ command.revision, self.cfg, autogenerate=True
+ )
+
+
class UpgradeDowngradeStampTest(TestBase):
def setUp(self):
@@ -134,9 +284,68 @@ class UpgradeDowngradeStampTest(TestBase):
assert "DROP STEP 2" in buf.getvalue()
assert "DROP STEP 1" not in buf.getvalue()
- def test_stamp(self):
+ def test_sql_stamp_from_rev(self):
+ with capture_context_buffer() as buf:
+ command.stamp(self.cfg, "%s:head" % self.a, sql=True)
+ assert (
+ "UPDATE alembic_version "
+ "SET version_num='%s' "
+ "WHERE alembic_version.version_num = '%s';" % (self.c, self.a)
+ ) in buf.getvalue()
+
+ def test_sql_stamp_from_partial_rev(self):
with capture_context_buffer() as buf:
- command.stamp(self.cfg, "head", sql=True)
- assert "UPDATE alembic_version "\
- "SET version_num='%s';" % self.c in buf.getvalue()
+ command.stamp(self.cfg, "%s:head" % self.a[0:3], sql=True)
+ assert (
+ "UPDATE alembic_version "
+ "SET version_num='%s' "
+ "WHERE alembic_version.version_num = '%s';" % (self.c, self.a)
+ ) in buf.getvalue()
+
+
+class LiveStampTest(TestBase):
+ __only_on__ = 'sqlite'
+
+ def setUp(self):
+ self.bind = _sqlite_file_db()
+ self.env = staging_env()
+ self.cfg = _sqlite_testing_config()
+ self.a = a = util.rev_id()
+ self.b = b = util.rev_id()
+ script = ScriptDirectory.from_config(self.cfg)
+ script.generate_revision(a, None, refresh=True)
+ write_script(script, a, """
+revision = '%s'
+down_revision = None
+""" % a)
+ script.generate_revision(b, None, refresh=True)
+ write_script(script, b, """
+revision = '%s'
+down_revision = '%s'
+""" % (b, a))
+
+ def tearDown(self):
+ clear_staging_env()
+
+ def test_stamp_creates_table(self):
+ command.stamp(self.cfg, "head")
+ eq_(
+ self.bind.scalar("select version_num from alembic_version"),
+ self.b
+ )
+
+ def test_stamp_existing_upgrade(self):
+ command.stamp(self.cfg, self.a)
+ command.stamp(self.cfg, self.b)
+ eq_(
+ self.bind.scalar("select version_num from alembic_version"),
+ self.b
+ )
+ def test_stamp_existing_downgrade(self):
+ command.stamp(self.cfg, self.b)
+ command.stamp(self.cfg, self.a)
+ eq_(
+ self.bind.scalar("select version_num from alembic_version"),
+ self.a
+ )
diff --git a/tests/test_offline_environment.py b/tests/test_offline_environment.py
index b870dc4..684e350 100644
--- a/tests/test_offline_environment.py
+++ b/tests/test_offline_environment.py
@@ -1,11 +1,11 @@
-from alembic.testing.fixtures import TestBase
+from alembic.testing.fixtures import TestBase, capture_context_buffer
from alembic import command, util
from alembic.testing import assert_raises_message
from alembic.testing.env import staging_env, _no_sql_testing_config, \
three_rev_fixture, clear_staging_env, env_file_fixture
-
+import re
a = b = c = None
@@ -53,14 +53,14 @@ assert context.get_starting_revision_argument() == 'x'
command.upgrade(self.cfg, "x:y", sql=True)
command.downgrade(self.cfg, "x:y", sql=True)
- def test_starting_rev_pre_context_stamp(self):
+ def test_starting_rev_pre_context_cmd_w_no_startrev(self):
env_file_fixture("""
assert context.get_starting_revision_argument() == 'x'
""")
assert_raises_message(
util.CommandError,
"No starting revision argument is available.",
- command.stamp, self.cfg, a)
+ command.current, self.cfg)
def test_starting_rev_current_pre_context(self):
env_file_fixture("""
@@ -165,3 +165,13 @@ assert not context.requires_connection()
""")
command.upgrade(self.cfg, a, sql=True)
command.downgrade(self.cfg, "%s:%s" % (b, a), sql=True)
+
+ def test_running_comments_not_in_sql(self):
+
+ message = "this is a very long \nand multiline\nmessage"
+
+ d = command.revision(self.cfg, message=message)
+ with capture_context_buffer(transactional_ddl=True) as buf:
+ command.upgrade(self.cfg, "%s:%s" % (a, d.revision), sql=True)
+
+ assert not re.match(r".*-- .*and multiline", buf.getvalue(), re.S | re.M)
diff --git a/tests/test_revision.py b/tests/test_revision.py
new file mode 100644
index 0000000..10fae91
--- /dev/null
+++ b/tests/test_revision.py
@@ -0,0 +1,717 @@
+from alembic.testing.fixtures import TestBase
+from alembic.testing import eq_, assert_raises_message
+from alembic.revision import RevisionMap, Revision, MultipleHeads, \
+ RevisionError
+
+
+class APITest(TestBase):
+ def test_add_revision_one_head(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c', ('b',)),
+ ]
+ )
+ eq_(map_.heads, ('c', ))
+
+ map_.add_revision(Revision('d', ('c', )))
+ eq_(map_.heads, ('d', ))
+
+ def test_add_revision_two_head(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c1', ('b',)),
+ Revision('c2', ('b',)),
+ ]
+ )
+ eq_(map_.heads, ('c1', 'c2'))
+
+ map_.add_revision(Revision('d1', ('c1', )))
+ eq_(map_.heads, ('c2', 'd1'))
+
+ def test_get_revision_head_single(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c', ('b',)),
+ ]
+ )
+ eq_(map_.get_revision('head'), map_._revision_map['c'])
+
+ def test_get_revision_base_single(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c', ('b',)),
+ ]
+ )
+ eq_(map_.get_revision('base'), None)
+
+ def test_get_revision_head_multiple(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c1', ('b',)),
+ Revision('c2', ('b',)),
+ ]
+ )
+ assert_raises_message(
+ MultipleHeads,
+ "Multiple heads are present",
+ map_.get_revision, 'head'
+ )
+
+ def test_get_revision_heads_multiple(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c1', ('b',)),
+ Revision('c2', ('b',)),
+ ]
+ )
+ assert_raises_message(
+ MultipleHeads,
+ "Multiple heads are present",
+ map_.get_revision, "heads"
+ )
+
+ def test_get_revision_base_multiple(self):
+ map_ = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c', ()),
+ Revision('d', ('c',)),
+ ]
+ )
+ eq_(map_.get_revision('base'), None)
+
+
+class DownIterateTest(TestBase):
+ def _assert_iteration(
+ self, upper, lower, assertion, inclusive=True, map_=None,
+ implicit_base=False):
+ if map_ is None:
+ map_ = self.map
+ eq_(
+ [
+ rev.revision for rev in
+ map_.iterate_revisions(
+ upper, lower,
+ inclusive=inclusive, implicit_base=implicit_base
+ )
+ ],
+ assertion
+ )
+
+
+class DiamondTest(DownIterateTest):
+ def setUp(self):
+ self.map = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b1', ('a',)),
+ Revision('b2', ('a',)),
+ Revision('c', ('b1', 'b2')),
+ Revision('d', ('c',)),
+ ]
+ )
+
+ def test_iterate_simple_diamond(self):
+ self._assert_iteration(
+ "d", "a",
+ ["d", "c", "b1", "b2", "a"]
+ )
+
+
+class LabeledBranchTest(DownIterateTest):
+ def test_dupe_branch_collection(self):
+ fn = lambda: [
+ Revision('a', ()),
+ Revision('b', ('a',)),
+ Revision('c', ('b',), branch_labels=['xy1']),
+ Revision('d', ()),
+ Revision('e', ('d',), branch_labels=['xy1']),
+ Revision('f', ('e',))
+ ]
+ assert_raises_message(
+ RevisionError,
+ r"Branch name 'xy1' in revision (?:e|c) already "
+ "used by revision (?:e|c)",
+ getattr, RevisionMap(fn), "_revision_map"
+ )
+
+ def test_filter_for_lineage_labeled_head_across_merge(self):
+ fn = lambda: [
+ Revision('a', ()),
+ Revision('b', ('a', )),
+ Revision('c1', ('b', ), branch_labels='c1branch'),
+ Revision('c2', ('b', )),
+ Revision('d', ('c1', 'c2')),
+
+ ]
+ map_ = RevisionMap(fn)
+ c1 = map_.get_revision('c1')
+ c2 = map_.get_revision('c2')
+ d = map_.get_revision('d')
+ eq_(
+ map_.filter_for_lineage([c1, c2, d], "c1branch@head"),
+ [c1, c2, d]
+ )
+
+ def setUp(self):
+ self.map = RevisionMap(lambda: [
+ Revision('a', (), branch_labels='abranch'),
+ Revision('b', ('a',)),
+ Revision('somelongername', ('b',)),
+ Revision('c', ('somelongername',)),
+ Revision('d', ()),
+ Revision('e', ('d',), branch_labels=['ebranch']),
+ Revision('someothername', ('e',)),
+ Revision('f', ('someothername',)),
+ ])
+
+ def test_get_base_revisions_labeled(self):
+ eq_(
+ self.map._get_base_revisions("somelongername@base"),
+ ['a']
+ )
+
+ def test_get_current_named_rev(self):
+ eq_(
+ self.map.get_revision("ebranch@head"),
+ self.map.get_revision("f")
+ )
+
+ def test_get_base_revisions(self):
+ eq_(
+ self.map._get_base_revisions("base"),
+ ['a', 'd']
+ )
+
+ def test_iterate_head_to_named_base(self):
+ self._assert_iteration(
+ "heads", "ebranch@base",
+ ['f', 'someothername', 'e', 'd']
+ )
+
+ self._assert_iteration(
+ "heads", "abranch@base",
+ ['c', 'somelongername', 'b', 'a']
+ )
+
+ def test_iterate_named_head_to_base(self):
+ self._assert_iteration(
+ "ebranch@head", "base",
+ ['f', 'someothername', 'e', 'd']
+ )
+
+ self._assert_iteration(
+ "abranch@head", "base",
+ ['c', 'somelongername', 'b', 'a']
+ )
+
+ def test_iterate_named_head_to_heads(self):
+ self._assert_iteration(
+ "heads", "ebranch@head",
+ ['f'],
+ inclusive=True
+ )
+
+ def test_iterate_named_rev_to_heads(self):
+ self._assert_iteration(
+ "heads", "ebranch@d",
+ ['f', 'someothername', 'e', 'd'],
+ inclusive=True
+ )
+
+ def test_iterate_head_to_version_specific_base(self):
+ self._assert_iteration(
+ "heads", "e@base",
+ ['f', 'someothername', 'e', 'd']
+ )
+
+ self._assert_iteration(
+ "heads", "c@base",
+ ['c', 'somelongername', 'b', 'a']
+ )
+
+ def test_iterate_to_branch_at_rev(self):
+ self._assert_iteration(
+ "heads", "ebranch@d",
+ ['f', 'someothername', 'e', 'd']
+ )
+
+ def test_branch_w_down_relative(self):
+ self._assert_iteration(
+ "heads", "ebranch@-2",
+ ['f', 'someothername', 'e']
+ )
+
+ def test_branch_w_up_relative(self):
+ self._assert_iteration(
+ "ebranch@+2", "base",
+ ['someothername', 'e', 'd']
+ )
+
+ def test_partial_id_resolve(self):
+ eq_(self.map.get_revision("ebranch@some").revision, "someothername")
+ eq_(self.map.get_revision("abranch@some").revision, "somelongername")
+
+ def test_branch_at_heads(self):
+ eq_(
+ self.map.get_revision("abranch@heads").revision,
+ "c"
+ )
+
+ def test_branch_at_syntax(self):
+ eq_(self.map.get_revision("abranch@head").revision, 'c')
+ eq_(self.map.get_revision("abranch@base"), None)
+ eq_(self.map.get_revision("ebranch@head").revision, 'f')
+ eq_(self.map.get_revision("abranch@base"), None)
+ eq_(self.map.get_revision("ebranch@d").revision, 'd')
+
+ def test_branch_at_self(self):
+ eq_(self.map.get_revision("ebranch@ebranch").revision, 'e')
+
+ def test_retrieve_branch_revision(self):
+ eq_(self.map.get_revision("abranch").revision, 'a')
+ eq_(self.map.get_revision("ebranch").revision, 'e')
+
+ def test_rev_not_in_branch(self):
+ assert_raises_message(
+ RevisionError,
+ "Revision b is not a member of branch 'ebranch'",
+ self.map.get_revision, "ebranch@b"
+ )
+
+ assert_raises_message(
+ RevisionError,
+ "Revision d is not a member of branch 'abranch'",
+ self.map.get_revision, "abranch@d"
+ )
+
+ def test_no_revision_exists(self):
+ assert_raises_message(
+ RevisionError,
+ "No such revision or branch 'q'",
+ self.map.get_revision, "abranch@q"
+ )
+
+ def test_not_actually_a_branch(self):
+ eq_(self.map.get_revision("e@d").revision, "d")
+
+ def test_not_actually_a_branch_partial_resolution(self):
+ eq_(self.map.get_revision("someoth@d").revision, "d")
+
+ def test_no_such_branch(self):
+ assert_raises_message(
+ RevisionError,
+ "No such branch: 'x'",
+ self.map.get_revision, "x@d"
+ )
+
+
+class LongShortBranchTest(DownIterateTest):
+ def setUp(self):
+ self.map = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b1', ('a',)),
+ Revision('b2', ('a',)),
+ Revision('c1', ('b1',)),
+ Revision('d11', ('c1',)),
+ Revision('d12', ('c1',)),
+ ]
+ )
+
+ def test_iterate_full(self):
+ self._assert_iteration(
+ "heads", "base",
+ ['b2', 'd11', 'd12', 'c1', 'b1', 'a']
+ )
+
+
+class MultipleBranchTest(DownIterateTest):
+ def setUp(self):
+ self.map = RevisionMap(
+ lambda: [
+ Revision('a', ()),
+ Revision('b1', ('a',)),
+ Revision('b2', ('a',)),
+ Revision('cb1', ('b1',)),
+ Revision('cb2', ('b2',)),
+ Revision('d1cb1', ('cb1',)), # head
+ Revision('d2cb1', ('cb1',)), # head
+ Revision('d1cb2', ('cb2',)),
+ Revision('d2cb2', ('cb2',)),
+ Revision('d3cb2', ('cb2',)), # head
+ Revision('d1d2cb2', ('d1cb2', 'd2cb2')) # head + merge point
+ ]
+ )
+
+ def test_iterate_from_merge_point(self):
+ self._assert_iteration(
+ "d1d2cb2", "a",
+ ['d1d2cb2', 'd1cb2', 'd2cb2', 'cb2', 'b2', 'a']
+ )
+
+ def test_iterate_multiple_heads(self):
+ self._assert_iteration(
+ ["d2cb2", "d3cb2"], "a",
+ ['d2cb2', 'd3cb2', 'cb2', 'b2', 'a']
+ )
+
+ def test_iterate_single_branch(self):
+ self._assert_iteration(
+ "d3cb2", "a",
+ ['d3cb2', 'cb2', 'b2', 'a']
+ )
+
+ def test_iterate_single_branch_to_base(self):
+ self._assert_iteration(
+ "d3cb2", "base",
+ ['d3cb2', 'cb2', 'b2', 'a']
+ )
+
+ def test_iterate_multiple_branch_to_base(self):
+ self._assert_iteration(
+ ["d3cb2", "cb1"], "base",
+ ['d3cb2', 'cb2', 'b2', 'cb1', 'b1', 'a']
+ )
+
+ def test_iterate_multiple_heads_single_base(self):
+ # head d1cb1 is omitted as it is not
+ # a descendant of b2
+ self._assert_iteration(
+ ["d1cb1", "d2cb2", "d3cb2"], "b2",
+ ["d2cb2", 'd3cb2', 'cb2', 'b2']
+ )
+
+ def test_same_branch_wrong_direction(self):
+ # nodes b1 and d1cb1 are connected, but
+ # db1cb1 is the descendant of b1
+ assert_raises_message(
+ RevisionError,
+ r"Revision d1cb1 is not an ancestor of revision b1",
+ list,
+ self.map._iterate_revisions('b1', 'd1cb1')
+ )
+
+ def test_distinct_branches(self):
+ # nodes db2cb2 and b1 have no path to each other
+ assert_raises_message(
+ RevisionError,
+ r"Revision b1 is not an ancestor of revision d2cb2",
+ list,
+ self.map._iterate_revisions('d2cb2', 'b1')
+ )
+
+ def test_wrong_direction_to_base(self):
+ assert_raises_message(
+ RevisionError,
+ r"Revision d1cb1 is not an ancestor of revision base",
+ list,
+ self.map._iterate_revisions(None, 'd1cb1')
+ )
+
+ assert_raises_message(
+ RevisionError,
+ r"Revision d1cb1 is not an ancestor of revision base",
+ list,
+ self.map._iterate_revisions((), 'd1cb1')
+ )
+
+
+class BranchTravellingTest(DownIterateTest):
+ """test the order of revs when going along multiple branches.
+
+ We want depth-first along branches, but then we want to
+ terminate all branches at their branch point before continuing
+ to the nodes preceding that branch.
+
+ """
+
+ def setUp(self):
+ self.map = RevisionMap(
+ lambda: [
+ Revision('a1', ()),
+ Revision('a2', ('a1',)),
+ Revision('a3', ('a2',)),
+ Revision('b1', ('a3',)),
+ Revision('b2', ('a3',)),
+ Revision('cb1', ('b1',)),
+ Revision('cb2', ('b2',)),
+ Revision('db1', ('cb1',)),
+ Revision('db2', ('cb2',)),
+
+ Revision('e1b1', ('db1',)),
+ Revision('fe1b1', ('e1b1',)),
+
+ Revision('e2b1', ('db1',)),
+ Revision('e2b2', ('db2',)),
+ Revision("merge", ('e2b1', 'e2b2'))
+ ]
+ )
+
+ def test_iterate_one_branch_both_to_merge(self):
+ # test that when we hit a merge point, implicit base will
+ # ensure all branches that supply the merge point are filled in
+ self._assert_iteration(
+ "merge", "db1",
+ ['merge',
+ 'e2b1', 'db1',
+ 'e2b2', 'db2', 'cb2', 'b2'],
+ implicit_base=True
+ )
+
+ def test_three_branches_end_in_single_branch(self):
+
+ self._assert_iteration(
+ ["merge", "fe1b1"], "a3",
+ ['merge', 'e2b1', 'e2b2', 'db2', 'cb2', 'b2',
+ 'fe1b1', 'e1b1', 'db1', 'cb1', 'b1', 'a3']
+ )
+
+ def test_two_branches_to_root(self):
+
+ # here we want 'a3' as a "stop" branch point, but *not*
+ # 'db1', as we don't have multiple traversals on db1
+ self._assert_iteration(
+ "merge", "a1",
+ ['merge',
+ 'e2b1', 'db1', 'cb1', 'b1', # e2b1 branch
+ 'e2b2', 'db2', 'cb2', 'b2', # e2b2 branch
+ 'a3', # both terminate at a3
+ 'a2', 'a1' # finish out
+ ] # noqa
+ )
+
+ def test_two_branches_end_in_branch(self):
+ self._assert_iteration(
+ "merge", "b1",
+ # 'b1' is local to 'e2b1'
+ # branch so that is all we get
+ ['merge', 'e2b1', 'db1', 'cb1', 'b1',
+
+ ] # noqa
+ )
+
+ def test_two_branches_end_behind_branch(self):
+ self._assert_iteration(
+ "merge", "a2",
+ ['merge',
+ 'e2b1', 'db1', 'cb1', 'b1', # e2b1 branch
+ 'e2b2', 'db2', 'cb2', 'b2', # e2b2 branch
+ 'a3', # both terminate at a3
+ 'a2'
+ ] # noqa
+ )
+
+ def test_three_branches_to_root(self):
+
+ # in this case, both "a3" and "db1" are stop points
+ self._assert_iteration(
+ ["merge", "fe1b1"], "a1",
+ ['merge',
+ 'e2b1', # e2b1 branch
+ 'e2b2', 'db2', 'cb2', 'b2', # e2b2 branch
+ 'fe1b1', 'e1b1', # fe1b1 branch
+ 'db1', # fe1b1 and e2b1 branches terminate at db1
+ 'cb1', 'b1', # e2b1 branch continued....might be nicer
+ # if this was before the e2b2 branch...
+ 'a3', # e2b1 and e2b2 branches terminate at a3
+ 'a2', 'a1' # finish out
+ ] # noqa
+ )
+
+ def test_three_branches_end_multiple_bases(self):
+
+ # in this case, both "a3" and "db1" are stop points
+ self._assert_iteration(
+ ["merge", "fe1b1"], ["cb1", "cb2"],
+ [
+ 'merge',
+ 'e2b1',
+ 'e2b2', 'db2', 'cb2',
+ 'fe1b1', 'e1b1',
+ 'db1',
+ 'cb1'
+ ]
+ )
+
+ def test_three_branches_end_multiple_bases_exclusive(self):
+
+ self._assert_iteration(
+ ["merge", "fe1b1"], ["cb1", "cb2"],
+ [
+ 'merge',
+ 'e2b1',
+ 'e2b2', 'db2',
+ 'fe1b1', 'e1b1',
+ 'db1',
+ ],
+ inclusive=False
+ )
+
+ def test_detect_invalid_head_selection(self):
+ # db1 is an ancestor of fe1b1
+ assert_raises_message(
+ RevisionError,
+ "Requested revision fe1b1 overlaps "
+ "with other requested revisions",
+ list,
+ self.map._iterate_revisions(["db1", "b2", "fe1b1"], ())
+ )
+
+ def test_three_branches_end_multiple_bases_exclusive_blank(self):
+ self._assert_iteration(
+ ["e2b1", "b2", "fe1b1"], (),
+ [
+ 'e2b1',
+ 'b2',
+ 'fe1b1', 'e1b1',
+ 'db1', 'cb1', 'b1', 'a3', 'a2', 'a1'
+ ],
+ inclusive=False
+ )
+
+ def test_iterate_to_symbolic_base(self):
+ self._assert_iteration(
+ ["fe1b1"], "base",
+ ['fe1b1', 'e1b1', 'db1', 'cb1', 'b1', 'a3', 'a2', 'a1'],
+ inclusive=False
+ )
+
+
+class MultipleBaseTest(DownIterateTest):
+ def setUp(self):
+ self.map = RevisionMap(
+ lambda: [
+ Revision('base1', ()),
+ Revision('base2', ()),
+ Revision('base3', ()),
+
+ Revision('a1a', ('base1',)),
+ Revision('a1b', ('base1',)),
+ Revision('a2', ('base2',)),
+ Revision('a3', ('base3',)),
+
+ Revision('b1a', ('a1a',)),
+ Revision('b1b', ('a1b',)),
+ Revision('b2', ('a2',)),
+ Revision('b3', ('a3',)),
+
+ Revision('c2', ('b2',)),
+ Revision('d2', ('c2',)),
+
+ Revision('mergeb3d2', ('b3', 'd2'))
+ ]
+ )
+
+ def test_heads_to_base(self):
+ self._assert_iteration(
+ "heads", "base",
+ [
+ 'b1a', 'a1a',
+ 'b1b', 'a1b',
+ 'mergeb3d2',
+ 'b3', 'a3', 'base3',
+ 'd2', 'c2', 'b2', 'a2', 'base2',
+ 'base1'
+ ]
+ )
+
+ def test_heads_to_base_exclusive(self):
+ self._assert_iteration(
+ "heads", "base",
+ [
+ 'b1a', 'a1a',
+ 'b1b', 'a1b',
+ 'mergeb3d2',
+ 'b3', 'a3', 'base3',
+ 'd2', 'c2', 'b2', 'a2', 'base2',
+ 'base1',
+ ],
+ inclusive=False
+ )
+
+ def test_heads_to_blank(self):
+ self._assert_iteration(
+ "heads", None,
+ [
+ 'b1a', 'a1a',
+ 'b1b', 'a1b',
+ 'mergeb3d2',
+ 'b3', 'a3', 'base3',
+ 'd2', 'c2', 'b2', 'a2', 'base2',
+ 'base1'
+ ]
+ )
+
+ def test_detect_invalid_base_selection(self):
+ assert_raises_message(
+ RevisionError,
+ "Requested revision a2 overlaps with "
+ "other requested revisions",
+ list,
+ self.map._iterate_revisions(["c2"], ["a2", "b2"])
+ )
+
+ def test_heads_to_revs_plus_implicit_base_exclusive(self):
+ self._assert_iteration(
+ "heads", ["c2"],
+ [
+ 'b1a', 'a1a',
+ 'b1b', 'a1b',
+ 'mergeb3d2',
+ 'b3', 'a3', 'base3',
+ 'd2',
+ 'base1'
+ ],
+ inclusive=False,
+ implicit_base=True
+ )
+
+ def test_heads_to_revs_base_exclusive(self):
+ self._assert_iteration(
+ "heads", ["c2"],
+ [
+ 'mergeb3d2', 'd2'
+ ],
+ inclusive=False
+ )
+
+ def test_heads_to_revs_plus_implicit_base_inclusive(self):
+ self._assert_iteration(
+ "heads", ["c2"],
+ [
+ 'b1a', 'a1a',
+ 'b1b', 'a1b',
+ 'mergeb3d2',
+ 'b3', 'a3', 'base3',
+ 'd2', 'c2',
+ 'base1'
+ ],
+ implicit_base=True
+ )
+
+ def test_specific_path_one(self):
+ self._assert_iteration(
+ "b3", "base3",
+ ['b3', 'a3', 'base3']
+ )
+
+ def test_specific_path_two_implicit_base(self):
+ self._assert_iteration(
+ ["b3", "b2"], "base3",
+ ['b3', 'a3', 'b2', 'a2', 'base2'],
+ inclusive=False, implicit_base=True
+ )
diff --git a/tests/test_script_consumption.py b/tests/test_script_consumption.py
index 5bf77bb..11b8080 100644
--- a/tests/test_script_consumption.py
+++ b/tests/test_script_consumption.py
@@ -229,7 +229,7 @@ class VersionNameTemplateTest(TestBase):
""" % a)
script = ScriptDirectory.from_config(self.cfg)
- rev = script._get_rev(a)
+ rev = script.get_revision(a)
eq_(rev.revision, a)
eq_(os.path.basename(rev.path), "myfile_some_message.py")
@@ -252,7 +252,7 @@ class VersionNameTemplateTest(TestBase):
""")
script = ScriptDirectory.from_config(self.cfg)
- rev = script._get_rev(a)
+ rev = script.get_revision(a)
eq_(rev.revision, a)
eq_(os.path.basename(rev.path), "%s.py" % a)
@@ -262,7 +262,7 @@ class VersionNameTemplateTest(TestBase):
a = util.rev_id()
script.generate_revision(a, "foobar", refresh=True)
- path = script._revision_map[a].path
+ path = script.get_revision(a).path
with open(path, 'w') as fp:
fp.write("""
down_revision = None
diff --git a/tests/test_script_production.py b/tests/test_script_production.py
index 2ed0653..647cf5b 100644
--- a/tests/test_script_production.py
+++ b/tests/test_script_production.py
@@ -1,8 +1,9 @@
from alembic.testing.fixtures import TestBase
-from alembic.testing import eq_, ne_, is_
+from alembic.testing import eq_, ne_, is_, assert_raises_message
from alembic.testing.env import clear_staging_env, staging_env, \
_get_staging_directory, _no_sql_testing_config, env_file_fixture, \
- script_file_fixture, _testing_config
+ script_file_fixture, _testing_config, _sqlite_testing_config, \
+ three_rev_fixture
from alembic import command
from alembic.script import ScriptDirectory
from alembic.environment import EnvironmentContext
@@ -72,7 +73,7 @@ class GeneralOrderedTests(TestBase):
'%s_this_is_the_next_rev.py' % def_), os.F_OK)
eq_(script.revision, def_)
eq_(script.down_revision, abc)
- eq_(env._revision_map[abc].nextrev, set([def_]))
+ eq_(env.get_revision(abc).nextrev, set([def_]))
assert script.module.down_revision == abc
assert callable(script.module.upgrade)
assert callable(script.module.downgrade)
@@ -84,8 +85,8 @@ class GeneralOrderedTests(TestBase):
# new ScriptDirectory instance.
env = staging_env(create=False)
- abc_rev = env._revision_map[abc]
- def_rev = env._revision_map[def_]
+ abc_rev = env.get_revision(abc)
+ def_rev = env.get_revision(def_)
eq_(abc_rev.nextrev, set([def_]))
eq_(abc_rev.revision, abc)
eq_(def_rev.down_revision, abc)
@@ -97,7 +98,7 @@ class GeneralOrderedTests(TestBase):
script = env.generate_revision(rid, "dont' refresh")
is_(script, None)
env2 = staging_env(create=False)
- eq_(env2._as_rev_number("head"), rid)
+ eq_(env2.get_current_head(), rid)
def _test_008_long_name(self):
rid = util.rev_id()
@@ -151,6 +152,74 @@ class ScriptNamingTest(TestBase):
)
+class RevisionCommandTest(TestBase):
+ def setUp(self):
+ self.env = staging_env()
+ self.cfg = _sqlite_testing_config()
+ self.a, self.b, self.c = three_rev_fixture(self.cfg)
+
+ def tearDown(self):
+ clear_staging_env()
+
+ def test_create_script_basic(self):
+ rev = command.revision(self.cfg, message="some message")
+ script = ScriptDirectory.from_config(self.cfg)
+ rev = script.get_revision(rev.revision)
+ eq_(rev.down_revision, self.c)
+ assert "some message" in rev.doc
+
+ def test_create_script_splice(self):
+ rev = command.revision(
+ self.cfg, message="some message", head=self.b, splice=True)
+ script = ScriptDirectory.from_config(self.cfg)
+ rev = script.get_revision(rev.revision)
+ eq_(rev.down_revision, self.b)
+ assert "some message" in rev.doc
+ eq_(set(script.get_heads()), set([rev.revision, self.c]))
+
+ def test_create_script_missing_splice(self):
+ assert_raises_message(
+ util.CommandError,
+ "Revision %s is not a head revision; please specify --splice "
+ "to create a new branch from this revision" % self.b,
+ command.revision,
+ self.cfg, message="some message", head=self.b
+ )
+
+ def test_create_script_branches(self):
+ rev = command.revision(
+ self.cfg, message="some message", branch_label="foobar")
+ script = ScriptDirectory.from_config(self.cfg)
+ rev = script.get_revision(rev.revision)
+ eq_(script.get_revision("foobar"), rev)
+
+ def test_create_script_branches_old_template(self):
+ script = ScriptDirectory.from_config(self.cfg)
+ with open(os.path.join(script.dir, "script.py.mako"), "w") as file_:
+ file_.write(
+ "<%text>#</%text> ${message}\n"
+ "revision = ${repr(up_revision)}\n"
+ "down_revision = ${repr(down_revision)}\n"
+ "def upgrade():\n"
+ " ${upgrades if upgrades else 'pass'}\n\n"
+ "def downgrade():\n"
+ " ${downgrade if downgrades else 'pass'}\n\n"
+ )
+
+ # works OK if no branch names
+ command.revision(self.cfg, message="some message")
+
+ assert_raises_message(
+ util.CommandError,
+ r"Version \w+ specified branch_labels foobar, "
+ r"however the migration file .+?\b does not have them; have you "
+ "upgraded your script.py.mako to include the 'branch_labels' "
+ r"section\?",
+ command.revision,
+ self.cfg, message="some message", branch_label="foobar"
+ )
+
+
class TemplateArgsTest(TestBase):
def setUp(self):
diff --git a/tests/test_version_table.py b/tests/test_version_table.py
index 28ac6ca..2271180 100644
--- a/tests/test_version_table.py
+++ b/tests/test_version_table.py
@@ -1,9 +1,10 @@
from alembic.testing.fixtures import TestBase
-from alembic.testing import config, eq_, assert_raises
+from alembic.testing import config, eq_, assert_raises, assert_raises_message
from sqlalchemy import Table, MetaData, Column, String
from sqlalchemy.engine.reflection import Inspector
+from alembic import migration
from alembic.util import CommandError
@@ -11,6 +12,18 @@ version_table = Table('version_table', MetaData(),
Column('version_num', String(32), nullable=False))
+def _up(from_, to_, branch_presence_changed=False):
+ return migration.StampStep(
+ from_, to_, True, branch_presence_changed
+ )
+
+
+def _down(from_, to_, branch_presence_changed=False):
+ return migration.StampStep(
+ from_, to_, False, branch_presence_changed
+ )
+
+
class TestMigrationContext(TestBase):
@classmethod
@@ -27,8 +40,7 @@ class TestMigrationContext(TestBase):
self.connection.close()
def make_one(self, **kwargs):
- from alembic.migration import MigrationContext
- return MigrationContext.configure(**kwargs)
+ return migration.MigrationContext.configure(**kwargs)
def get_revision(self):
result = self.connection.execute(version_table.select())
@@ -52,12 +64,12 @@ class TestMigrationContext(TestBase):
opts={'version_table_schema': 'explicit'})
eq_(context._version.schema, 'explicit')
- def test_get_current_revision_creates_version_table(self):
+ def test_get_current_revision_doesnt_create_version_table(self):
context = self.make_one(connection=self.connection,
opts={'version_table': 'version_table'})
eq_(context.get_current_revision(), None)
insp = Inspector(self.connection)
- assert ('version_table' in insp.get_table_names())
+ assert ('version_table' not in insp.get_table_names())
def test_get_current_revision(self):
context = self.make_one(connection=self.connection,
@@ -82,14 +94,161 @@ class TestMigrationContext(TestBase):
'as_sql': True})
eq_(context.get_current_revision(), 'startrev')
- def test__update_current_rev(self):
+ def test_get_current_revision_multiple_heads(self):
+ version_table.create(self.connection)
+ context = self.make_one(connection=self.connection,
+ opts={'version_table': 'version_table'})
+ updater = migration.HeadMaintainer(context, ())
+ updater.update_to_step(_up(None, 'a', True))
+ updater.update_to_step(_up(None, 'b', True))
+ assert_raises_message(
+ CommandError,
+ "Version table 'version_table' has more than one head present; "
+ "please use get_current_heads()",
+ context.get_current_revision
+ )
+
+ def test_get_heads(self):
version_table.create(self.connection)
context = self.make_one(connection=self.connection,
opts={'version_table': 'version_table'})
+ updater = migration.HeadMaintainer(context, ())
+ updater.update_to_step(_up(None, 'a', True))
+ updater.update_to_step(_up(None, 'b', True))
+ eq_(context.get_current_heads(), ('a', 'b'))
+
+ def test_get_heads_offline(self):
+ version_table.create(self.connection)
+ context = self.make_one(connection=self.connection,
+ opts={
+ 'starting_rev': 'q',
+ 'version_table': 'version_table',
+ 'as_sql': True})
+ eq_(context.get_current_heads(), ('q', ))
+
+
+class UpdateRevTest(TestBase):
+
+ @classmethod
+ def setup_class(cls):
+ cls.bind = config.db
+
+ def setUp(self):
+ self.connection = self.bind.connect()
+ self.context = migration.MigrationContext.configure(
+ connection=self.connection,
+ opts={"version_table": "version_table"})
+ version_table.create(self.connection)
+ self.updater = migration.HeadMaintainer(self.context, ())
+
+ def tearDown(self):
+ version_table.drop(self.connection, checkfirst=True)
+ self.connection.close()
+
+ def _assert_heads(self, heads):
+ eq_(self.context.get_current_heads(), heads)
+ eq_(self.updater.heads, set(heads))
+
+ def test_update_none_to_single(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self._assert_heads(('a',))
+
+ def test_update_single_to_single(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self.updater.update_to_step(_up('a', 'b'))
+ self._assert_heads(('b',))
+
+ def test_update_single_to_none(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self.updater.update_to_step(_down('a', None, True))
+ self._assert_heads(())
+
+ def test_add_branches(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self.updater.update_to_step(_up('a', 'b'))
+ self.updater.update_to_step(_up(None, 'c', True))
+ self._assert_heads(('b', 'c'))
+ self.updater.update_to_step(_up('c', 'd'))
+ self.updater.update_to_step(_up('d', 'e1'))
+ self.updater.update_to_step(_up('d', 'e2', True))
+ self._assert_heads(('b', 'e1', 'e2'))
+
+ def test_teardown_branches(self):
+ self.updater.update_to_step(_up(None, 'd1', True))
+ self.updater.update_to_step(_up(None, 'd2', True))
+ self._assert_heads(('d1', 'd2'))
+
+ self.updater.update_to_step(_down('d1', 'c'))
+ self._assert_heads(('c', 'd2'))
+
+ self.updater.update_to_step(_down('d2', 'c', True))
+
+ self._assert_heads(('c',))
+ self.updater.update_to_step(_down('c', 'b'))
+ self._assert_heads(('b',))
+
+ def test_resolve_merges(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self.updater.update_to_step(_up('a', 'b'))
+ self.updater.update_to_step(_up('b', 'c1'))
+ self.updater.update_to_step(_up('b', 'c2', True))
+ self.updater.update_to_step(_up('c1', 'd1'))
+ self.updater.update_to_step(_up('c2', 'd2'))
+ self._assert_heads(('d1', 'd2'))
+ self.updater.update_to_step(_up(('d1', 'd2'), 'e'))
+ self._assert_heads(('e',))
+
+ def test_unresolve_merges(self):
+ self.updater.update_to_step(_up(None, 'e', True))
+
+ self.updater.update_to_step(_down('e', ('d1', 'd2')))
+ self._assert_heads(('d2', 'd1'))
+
+ self.updater.update_to_step(_down('d2', 'c2'))
+ self._assert_heads(('c2', 'd1'))
+
+ def test_update_no_match(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+ self.updater.heads.add('x')
+ assert_raises_message(
+ CommandError,
+ "Online migration expected to match one row when updating "
+ "'x' to 'b' in 'version_table'; 0 found",
+ self.updater.update_to_step, _up('x', 'b')
+ )
+
+ def test_update_multi_match(self):
+ self.connection.execute(version_table.insert(), version_num='a')
+ self.connection.execute(version_table.insert(), version_num='a')
+
+ self.updater.heads.add('a')
+ assert_raises_message(
+ CommandError,
+ "Online migration expected to match one row when updating "
+ "'a' to 'b' in 'version_table'; 2 found",
+ self.updater.update_to_step, _up('a', 'b')
+ )
+
+ def test_delete_no_match(self):
+ self.updater.update_to_step(_up(None, 'a', True))
+
+ self.updater.heads.add('x')
+ assert_raises_message(
+ CommandError,
+ "Online migration expected to match one row when "
+ "deleting 'x' in 'version_table'; 0 found",
+ self.updater.update_to_step, _down('x', None, True)
+ )
+
+ def test_delete_multi_match(self):
+ self.connection.execute(version_table.insert(), version_num='a')
+ self.connection.execute(version_table.insert(), version_num='a')
+
+ self.updater.heads.add('a')
+ assert_raises_message(
+ CommandError,
+ "Online migration expected to match one row when "
+ "deleting 'a' in 'version_table'; 2 found",
+ self.updater.update_to_step, _down('a', None, True)
+ )
- context._update_current_rev(None, 'a')
- eq_(self.get_revision(), 'a')
- context._update_current_rev('a', 'b')
- eq_(self.get_revision(), 'b')
- context._update_current_rev('b', None)
- eq_(self.get_revision(), None)
diff --git a/tests/test_version_traversal.py b/tests/test_version_traversal.py
index 82fe9b6..8fcf8e9 100644
--- a/tests/test_version_traversal.py
+++ b/tests/test_version_traversal.py
@@ -2,146 +2,450 @@ from alembic.testing.env import clear_staging_env, staging_env
from alembic.testing import assert_raises_message, eq_
from alembic import util
from alembic.testing.fixtures import TestBase
+from alembic.testing import mock
+from alembic.migration import MigrationStep, HeadMaintainer
-env = None
-a, b, c, d, e = None, None, None, None, None
-cfg = None
+class MigrationTest(TestBase):
+ def up_(self, rev):
+ return MigrationStep.upgrade_from_script(
+ self.env.revision_map, rev)
+ def down_(self, rev):
+ return MigrationStep.downgrade_from_script(
+ self.env.revision_map, rev)
+
+ def _assert_downgrade(self, destination, source, expected, expected_heads):
+ revs = self.env._downgrade_revs(destination, source)
+ eq_(
+ revs, expected
+ )
+ heads = set(util.to_tuple(source, default=()))
+ head = HeadMaintainer(mock.Mock(), heads)
+ for rev in revs:
+ head.update_to_step(rev)
+ eq_(head.heads, expected_heads)
+
+ def _assert_upgrade(self, destination, source, expected, expected_heads):
+ revs = self.env._upgrade_revs(destination, source)
+ eq_(
+ revs, expected
+ )
+ heads = set(util.to_tuple(source, default=()))
+ head = HeadMaintainer(mock.Mock(), heads)
+ for rev in revs:
+ head.update_to_step(rev)
+ eq_(head.heads, expected_heads)
-class RevisionPathTest(TestBase):
+
+class RevisionPathTest(MigrationTest):
@classmethod
def setup_class(cls):
- global env
- env = staging_env()
- global a, b, c, d, e
- a = env.generate_revision(util.rev_id(), '->a', refresh=True)
- b = env.generate_revision(util.rev_id(), 'a->b', refresh=True)
- c = env.generate_revision(util.rev_id(), 'b->c', refresh=True)
- d = env.generate_revision(util.rev_id(), 'c->d', refresh=True)
- e = env.generate_revision(util.rev_id(), 'd->e', refresh=True)
+ cls.env = env = staging_env()
+ cls.a = env.generate_revision(util.rev_id(), '->a', refresh=True)
+ cls.b = env.generate_revision(util.rev_id(), 'a->b', refresh=True)
+ cls.c = env.generate_revision(util.rev_id(), 'b->c', refresh=True)
+ cls.d = env.generate_revision(util.rev_id(), 'c->d', refresh=True)
+ cls.e = env.generate_revision(util.rev_id(), 'd->e', refresh=True)
@classmethod
def teardown_class(cls):
clear_staging_env()
def test_upgrade_path(self):
-
- eq_(
- env._upgrade_revs(e.revision, c.revision),
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
+ self._assert_upgrade(
+ e.revision, c.revision,
[
- (d.module.upgrade, c.revision, d.revision, d.doc),
- (e.module.upgrade, d.revision, e.revision, e.doc),
- ]
+ self.up_(d),
+ self.up_(e)
+ ],
+ set([e.revision])
)
- eq_(
- env._upgrade_revs(c.revision, None),
+ self._assert_upgrade(
+ c.revision, None,
[
- (a.module.upgrade, None, a.revision, a.doc),
- (b.module.upgrade, a.revision, b.revision, b.doc),
- (c.module.upgrade, b.revision, c.revision, c.doc),
- ]
+ self.up_(a),
+ self.up_(b),
+ self.up_(c),
+ ],
+ set([c.revision])
)
def test_relative_upgrade_path(self):
- eq_(
- env._upgrade_revs("+2", a.revision),
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
+ self._assert_upgrade(
+ "+2", a.revision,
[
- (b.module.upgrade, a.revision, b.revision, b.doc),
- (c.module.upgrade, b.revision, c.revision, c.doc),
- ]
+ self.up_(b),
+ self.up_(c),
+ ],
+ set([c.revision])
)
- eq_(
- env._upgrade_revs("+1", a.revision),
+ self._assert_upgrade(
+ "+1", a.revision,
[
- (b.module.upgrade, a.revision, b.revision, b.doc),
- ]
+ self.up_(b)
+ ],
+ set([b.revision])
)
- eq_(
- env._upgrade_revs("+3", b.revision),
- [
- (c.module.upgrade, b.revision, c.revision, c.doc),
- (d.module.upgrade, c.revision, d.revision, d.doc),
- (e.module.upgrade, d.revision, e.revision, e.doc),
- ]
+ self._assert_upgrade(
+ "+3", b.revision,
+ [self.up_(c), self.up_(d), self.up_(e)],
+ set([e.revision])
)
def test_invalid_relative_upgrade_path(self):
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
assert_raises_message(
util.CommandError,
"Relative revision -2 didn't produce 2 migrations",
- env._upgrade_revs, "-2", b.revision
+ self.env._upgrade_revs, "-2", b.revision
)
assert_raises_message(
util.CommandError,
r"Relative revision \+5 didn't produce 5 migrations",
- env._upgrade_revs, "+5", b.revision
+ self.env._upgrade_revs, "+5", b.revision
)
def test_downgrade_path(self):
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
- eq_(
- env._downgrade_revs(c.revision, e.revision),
- [
- (e.module.downgrade, e.revision, e.down_revision, e.doc),
- (d.module.downgrade, d.revision, d.down_revision, d.doc),
- ]
+ self._assert_downgrade(
+ c.revision, e.revision,
+ [self.down_(e), self.down_(d)],
+ set([c.revision])
)
- eq_(
- env._downgrade_revs(None, c.revision),
- [
- (c.module.downgrade, c.revision, c.down_revision, c.doc),
- (b.module.downgrade, b.revision, b.down_revision, b.doc),
- (a.module.downgrade, a.revision, a.down_revision, a.doc),
- ]
+ self._assert_downgrade(
+ None, c.revision,
+ [self.down_(c), self.down_(b), self.down_(a)],
+ set()
)
def test_relative_downgrade_path(self):
- eq_(
- env._downgrade_revs("-1", c.revision),
- [
- (c.module.downgrade, c.revision, c.down_revision, c.doc),
- ]
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
+ self._assert_downgrade(
+ "-1", c.revision,
+ [self.down_(c)],
+ set([b.revision])
)
- eq_(
- env._downgrade_revs("-3", e.revision),
- [
- (e.module.downgrade, e.revision, e.down_revision, e.doc),
- (d.module.downgrade, d.revision, d.down_revision, d.doc),
- (c.module.downgrade, c.revision, c.down_revision, c.doc),
- ]
+ self._assert_downgrade(
+ "-3", e.revision,
+ [self.down_(e), self.down_(d), self.down_(c)],
+ set([b.revision])
)
def test_invalid_relative_downgrade_path(self):
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
assert_raises_message(
util.CommandError,
"Relative revision -5 didn't produce 5 migrations",
- env._downgrade_revs, "-5", b.revision
+ self.env._downgrade_revs, "-5", b.revision
)
assert_raises_message(
util.CommandError,
r"Relative revision \+2 didn't produce 2 migrations",
- env._downgrade_revs, "+2", b.revision
+ self.env._downgrade_revs, "+2", b.revision
)
def test_invalid_move_rev_to_none(self):
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
assert_raises_message(
util.CommandError,
- "Revision %s is not an ancestor of base" % b.revision,
- env._downgrade_revs, b.revision[0:3], None
+ r"Destination %s is not a valid downgrade "
+ "target from current head\(s\)" % b.revision[0:3],
+ self.env._downgrade_revs, b.revision[0:3], None
)
def test_invalid_move_higher_to_lower(self):
+ a, b, c, d, e = self.a, self.b, self.c, self.d, self.e
+
assert_raises_message(
util.CommandError,
- "Revision %s is not an ancestor of %s" % (c.revision, b.revision),
- env._downgrade_revs, c.revision[0:4], b.revision
+ r"Destination %s is not a valid downgrade "
+ "target from current head\(s\)" % c.revision[0:4],
+ self.env._downgrade_revs, c.revision[0:4], b.revision
+ )
+
+
+class BranchedPathTest(MigrationTest):
+
+ @classmethod
+ def setup_class(cls):
+ cls.env = env = staging_env()
+ cls.a = env.generate_revision(util.rev_id(), '->a', refresh=True)
+ cls.b = env.generate_revision(util.rev_id(), 'a->b', refresh=True)
+
+ cls.c1 = env.generate_revision(
+ util.rev_id(), 'b->c1',
+ branch_labels='c1branch',
+ refresh=True)
+ cls.d1 = env.generate_revision(util.rev_id(), 'c1->d1', refresh=True)
+
+ cls.c2 = env.generate_revision(
+ util.rev_id(), 'b->c2',
+ branch_labels='c2branch',
+ head=cls.b.revision, refresh=True, splice=True)
+ cls.d2 = env.generate_revision(
+ util.rev_id(), 'c2->d2',
+ head=cls.c2.revision, refresh=True)
+
+ @classmethod
+ def teardown_class(cls):
+ clear_staging_env()
+
+ def test_stamp_down_across_multiple_branch_to_branchpoint(self):
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+ revs = self.env._stamp_revs(
+ self.b.revision, [self.d1.revision, self.c2.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # DELETE d1 revision, UPDATE c2 to b
+ ([self.d1.revision], self.c2.revision, self.b.revision)
+ )
+
+ def test_stamp_to_labeled_base_multiple_heads(self):
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+ revs = self.env._stamp_revs(
+ "c1branch@base", [self.d1.revision, self.c2.revision])
+ eq_(len(revs), 1)
+ assert revs[0].should_delete_branch
+ eq_(revs[0].delete_version_num, self.d1.revision)
+
+ def test_stamp_to_labeled_head_multiple_heads(self):
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+ revs = self.env._stamp_revs(
+ "c2branch@head", [self.d1.revision, self.c2.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # the c1branch remains unchanged
+ ([], self.c2.revision, self.d2.revision)
+ )
+
+ def test_upgrade_single_branch(self):
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+
+ self._assert_upgrade(
+ d1.revision, b.revision,
+ [self.up_(c1), self.up_(d1)],
+ set([d1.revision])
+ )
+
+ def test_upgrade_multiple_branch(self):
+ # move from a single head to multiple heads
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+
+ self._assert_upgrade(
+ (d1.revision, d2.revision), a.revision,
+ [self.up_(b), self.up_(c2), self.up_(d2),
+ self.up_(c1), self.up_(d1)],
+ set([d1.revision, d2.revision])
+ )
+
+ def test_downgrade_multiple_branch(self):
+ a, b, c1, d1, c2, d2 = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2
+ )
+ self._assert_downgrade(
+ a.revision, (d1.revision, d2.revision),
+ [self.down_(d1), self.down_(c1), self.down_(d2),
+ self.down_(c2), self.down_(b)],
+ set([a.revision])
+ )
+
+
+class ForestTest(MigrationTest):
+ @classmethod
+ def setup_class(cls):
+ cls.env = env = staging_env()
+ cls.a1 = env.generate_revision(util.rev_id(), '->a1', refresh=True)
+ cls.b1 = env.generate_revision(util.rev_id(), 'a1->b1', refresh=True)
+
+ cls.a2 = env.generate_revision(
+ util.rev_id(), '->a2', head=(),
+ refresh=True)
+ cls.b2 = env.generate_revision(
+ util.rev_id(), 'a2->b2', head=cls.a2.revision, refresh=True)
+
+ @classmethod
+ def teardown_class(cls):
+ clear_staging_env()
+
+ def test_base_to_heads(self):
+ a1, b1, a2, b2 = self.a1, self.b1, self.a2, self.b2
+ eq_(
+ self.env._upgrade_revs("heads", "base"),
+ [self.up_(a2), self.up_(b2), self.up_(a1), self.up_(b1), ]
+ )
+
+
+class MergedPathTest(MigrationTest):
+
+ @classmethod
+ def setup_class(cls):
+ cls.env = env = staging_env()
+ cls.a = env.generate_revision(util.rev_id(), '->a', refresh=True)
+ cls.b = env.generate_revision(util.rev_id(), 'a->b', refresh=True)
+
+ cls.c1 = env.generate_revision(util.rev_id(), 'b->c1', refresh=True)
+ cls.d1 = env.generate_revision(util.rev_id(), 'c1->d1', refresh=True)
+
+ cls.c2 = env.generate_revision(
+ util.rev_id(), 'b->c2',
+ branch_labels='c2branch',
+ head=cls.b.revision, refresh=True, splice=True)
+ cls.d2 = env.generate_revision(
+ util.rev_id(), 'c2->d2',
+ head=cls.c2.revision, refresh=True)
+
+ cls.e = env.generate_revision(
+ util.rev_id(), 'merge d1 and d2',
+ head=(cls.d1.revision, cls.d2.revision), refresh=True
+ )
+
+ cls.f = env.generate_revision(util.rev_id(), 'e->f', refresh=True)
+
+ @classmethod
+ def teardown_class(cls):
+ clear_staging_env()
+
+ def test_stamp_down_across_merge_point_branch(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ revs = self.env._stamp_revs(self.c2.revision, [self.e.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # no deletes, UPDATE e to c2
+ ([], self.e.revision, self.c2.revision)
+ )
+
+ def test_stamp_down_across_merge_prior_branching(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ revs = self.env._stamp_revs(self.a.revision, [self.e.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # no deletes, UPDATE e to c2
+ ([], self.e.revision, self.a.revision)
+ )
+
+ def test_stamp_up_across_merge_from_single_branch(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ revs = self.env._stamp_revs(self.e.revision, [self.c2.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # no deletes, UPDATE e to c2
+ ([], self.c2.revision, self.e.revision)
+ )
+
+ def test_stamp_labled_head_across_merge_from_multiple_branch(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ # this is testing that filter_for_lineage() checks for
+ # d1 both in terms of "c2branch" as well as that the "head"
+ # revision "f" is the head of both d1 and d2
+ revs = self.env._stamp_revs(
+ "c2branch@head", [self.d1.revision, self.c2.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # DELETE d1 revision, UPDATE c2 to e
+ ([self.d1.revision], self.c2.revision, self.f.revision)
+ )
+
+ def test_stamp_up_across_merge_from_multiple_branch(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ revs = self.env._stamp_revs(
+ self.e.revision, [self.d1.revision, self.c2.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # DELETE d1 revision, UPDATE c2 to e
+ ([self.d1.revision], self.c2.revision, self.e.revision)
+ )
+
+ def test_stamp_up_across_merge_prior_branching(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+ revs = self.env._stamp_revs(self.e.revision, [self.b.revision])
+ eq_(len(revs), 1)
+ eq_(
+ revs[0].merge_branch_idents,
+ # no deletes, UPDATE e to c2
+ ([], self.b.revision, self.e.revision)
+ )
+
+ def test_upgrade_across_merge_point(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+
+ eq_(
+ self.env._upgrade_revs(f.revision, b.revision),
+ [
+ self.up_(c2),
+ self.up_(d2),
+ self.up_(c1), # b->c1, create new branch
+ self.up_(d1),
+ self.up_(e), # d1/d2 -> e, merge branches
+ # (DELETE d2, UPDATE d1->e)
+ self.up_(f)
+ ]
+ )
+
+ def test_downgrade_across_merge_point(self):
+ a, b, c1, d1, c2, d2, e, f = (
+ self.a, self.b, self.c1, self.d1, self.c2, self.d2,
+ self.e, self.f
+ )
+
+ eq_(
+ self.env._downgrade_revs(b.revision, f.revision),
+ [
+ self.down_(f),
+ self.down_(e), # e -> d1 and d2, unmerge branches
+ # (UPDATE e->d1, INSERT d2)
+ self.down_(d1),
+ self.down_(c1),
+ self.down_(d2),
+ self.down_(c2), # c2->b, delete branch
+ ]
)