async def list( connection: asyncpg.Connection, table_schema: str = constants.MIGRATIONS_SCHEMA, table_name: str = constants.MIGRATIONS_TABLE, ) -> model.MigrationHistory: logger.debug('Getting a history of migrations') history = model.MigrationHistory() await connection.reload_schema_state() async with connection.transaction(): async for record in connection.cursor(""" select revision, label, timestamp, direction from {table_schema}.{table_name} order by timestamp asc; """.format( table_schema=table_schema, table_name=table_name, )): history.append( model.MigrationHistoryEntry( revision=model.Revision(record['revision']), label=record['label'], timestamp=record['timestamp'], direction=model.MigrationDir(record['direction']), ), ) return history
async def test_get_revision_migration_table_exists_with_entries( db_connection: asyncpg.Connection, table_schema: str, table_name: str, mocker: ptm.MockFixture, ) -> None: max_revisions = 10 await migration.create_table( connection=db_connection, table_schema=table_schema, table_name=table_name, ) for i in range(1, max_revisions + 1): await migration.save( connection=db_connection, migration=model.Migration( revision=model.Revision(i), label=__name__, path=mocker.stub(), upgrade=mocker.stub(), downgrade=mocker.stub(), ), direction=secrets.choice([ model.MigrationDir.DOWN, model.MigrationDir.UP, ]), table_schema=table_schema, table_name=table_name, ) assert (await migration.latest_revision( connection=db_connection, table_schema=table_schema, table_name=table_name, )) == max_revisions
async def latest_revision( connection: asyncpg.Connection, table_schema: str = constants.MIGRATIONS_SCHEMA, table_name: str = constants.MIGRATIONS_TABLE, ) -> t.Optional[model.Revision]: await connection.reload_schema_state() val = await connection.fetchval( """ select revision from {table_schema}.{table_name} order by timestamp desc limit 1; """.format( table_schema=table_schema, table_name=table_name, ), ) return model.Revision(val) if val is not None else None
def test_db_history( cli_runner: testing.CliRunner, mocker: ptm.MockFixture, entries_count: int, ) -> None: @dataclass class MockedConfig: database_dsn: str database_name: str entries = model.MigrationHistory([ model.MigrationHistoryEntry( revision=model.Revision(rev), timestamp=model.Timestamp(dt.datetime.today()), label=mocker.stub(), direction=model.MigrationDir.UP if rev % 2 else model.MigrationDir.DOWN, ) for rev in range(entries_count) ]) mocker.patch( 'asyncpg_migrate.loader.load_configuration', return_value=MockedConfig( 'postgres://*****:*****@test:5432/test', 'test', ), ) mocker.patch( 'asyncpg.connect', side_effect=asyncio.coroutine(lambda dsn: object()), ) mocker.patch( 'asyncpg_migrate.engine.migration.list', side_effect=asyncio.coroutine(lambda *args, **kwargs: entries), ) from asyncpg_migrate import main result = cli_runner.invoke(main.db, 'history') if not entries_count: assert result.exception is not None assert result.exit_code == 1 assert 'No revisions found, you might want to run some migrations first :)' in \ result.output else: assert result.exception is None assert result.exit_code == 0 assert len(result.output.split('\n')[3:]) - 1 == entries_count
async def latest_revision( connection: asyncpg.Connection, table_schema: str = constants.MIGRATIONS_SCHEMA, table_name: str = constants.MIGRATIONS_TABLE, ) -> t.Optional[model.Revision]: try: await connection.reload_schema_state() table_name_in_db = await connection.fetchval( """ select to_regclass('{schema}.{table}') """.format( schema=table_schema, table=table_name, ), ) db_revision = None if table_name_in_db is None: raise MigrationTableMissing(f'{table_name} table does not exist') else: val = await connection.fetchval( """ select revision from {table_schema}.{table_name} order by timestamp desc limit 1; """.format( table_schema=table_schema, table_name=table_name, ), ) db_revision = model.Revision(val) if val is not None else None return db_revision except MigrationTableMissing: logger.exception('Migration table seems to be missing') raise except Exception as ex: logger.exception( 'Unknown error occurred while getting latest revision') raise MigrationProcessingError() from ex
async def run( config: model.Config, target_revision: t.Union[str, int], connection: asyncpg.Connection, ) -> t.Optional[model.Revision]: logger.info( 'Downgrading to revision {target_revision} has been triggered', target_revision=target_revision, ) await migration.create_table(connection) migrations = loader.load_migrations(config) if not migrations: logger.info('There are no migrations scripts, skipping') return None elif str(target_revision).lower() == 'head': # although revision can be decoded from 'head' string # in downgraded only 'base' is supported raise ValueError('Cannot downgrade using "head"') else: to_revision = 1 if str( target_revision, ).lower() == 'base' else int(target_revision) maybe_db_revision = await migration.latest_revision(connection) if maybe_db_revision is None: logger.debug('No migration has ever happened, skipping...') return None elif maybe_db_revision == 0: logger.debug( 'Dowgraded everything there could have been migrated, skipping...') return None else: db_revision = maybe_db_revision to_revision = 1 if abs(to_revision) == 0 else to_revision if to_revision > 0: if to_revision > len(migrations): logger.error( 'Cannot downgrade further than I know scripts for') return None else: previous_db_revision = db_revision - (abs(to_revision) - 1) if previous_db_revision <= 0: to_revision = 1 else: to_revision = previous_db_revision if db_revision == 1: # special case, we are about to go back to the state as-if no # migration has happened, we will remove all the scripts apart # from first one migrations_to_apply = migrations.slice(start=1, end=1) else: migrations_to_apply = migrations.slice(start=to_revision, end=db_revision) logger.debug( f'Applying migrations {sorted(migrations_to_apply.keys(), reverse=True)}', ) async with connection.transaction(): try: for mig in migrations_to_apply.downgrade_iterator(): logger.debug(f'Applying {mig.revision}/{mig.label}') await mig.downgrade(connection) await migration.save( migration=mig, direction=model.MigrationDir.DOWN, connection=connection, ) await asyncio.sleep(1) last_completed_revision = mig.revision - 1 logger.info( 'Upgraded did manage to finish at {last_completed_revision} revision', last_completed_revision=last_completed_revision, ) return model.Revision(last_completed_revision) except Exception as ex: logger.exception('Failed to downgrade...') raise RuntimeError(str(ex))
@pytest.mark.parametrize( 'test_revision,expected_revision,all_revisions', [ ('HEAD', 2, [1, 2]), ('HEAD', ValueError, []), ('base', 1, [1, 2, 3, 4, 5]), ('base', ValueError, []), ('head', 3, [1, 2, 3]), ('BASE', 1, [1]), (1, 1, [1, 2]), (2, 2, [1, 2]), (-1, ValueError, [1, 2]), (-1, ValueError, []), (0, 0, [1, 2, 3]), (0, 0, []), (model.Revision(1), 1, [1, 2]), ('foo', ValueError, []), ('bar', ValueError, [1, 2]), ], ) def test_revision_decoding( test_revision: t.Union[str, int], expected_revision: t.Union[model.Revision, Exception], all_revisions: t.Sequence[model.Revision], ) -> None: if type(expected_revision) == int: assert model.Revision.decode( test_revision, all_revisions, ) == expected_revision else: