Migrator.NET — очень интересный и удобный инструмент поддрежки версионности и итерационного изменения баз данных для платформы .NET (обзор можно прочитать на хабре). Проект, возможно, еще не совсем зрелый, и при его использовании можно столкнуться с некоторыми проблемами. Но, благодаря разумной архитектуре и расширяемости, все эти проблемы легко решаются.

Атомарной единицей изменения базы данных в Migrator.NET является миграция — класс, в котором оперируя объектами предметной области мигратора (Database, Column и тд) можно определить действия, изменяющие структуру или данные в базе. Это удобно для стандартных действий — создать/изменить/переименовать таблицу и тому подобное. Но иногда требуется не просто изменить схему, а выполнить сложный запрос, пребрасывающий данные из одной таблицы в другую. Или просто сделать INSERT очень большого количества строк. Для этих целей в Migrator есть метод Database.ExecuteNonQuery, принимающий произвольную строку запроса. Однако у этого метода есть несколько недостатков:

  • Код SQL неудобно читать и править в C#-классе — нет подсветки синтаксиса и приходится экранировать специальные символы.
  • Если запрос проходит долго, то Migrator не может завершить миграцию и выдает сообщение об ошибке.

Последний пункт (он наиболее важен) связан с тем, что в .NET объект Command имеет свойство, определяющее timeout для этой команды. Значение этого таймаута нельзя задать вне кода, например в connection string или еще каким-то образом. Migrator, в свою очередь, тоже не предоставляет никаких средств для указания этого значения. А это значит, что если в Migration.Up поместить любой SQL-запрос, выполняющийся больше 30-ти секунд, то миграцию невозможно будет выполнить. Этот факт не остался незамеченым пользователями, вот соответствующая задача на google.code: Command Timeout while running a migration query. В качестве временного решения автор мигратора предложил увеличить Timeout до 90 секунд, однако это явно не выход.

Чтобы решить перечисленные проблемы можно написать собственную реализацию миграции, которая расширяет стандартный класс Migration возможностью выполнения SQL-запроса из файла и разбивки запроса на несколько независимых частей. Это решит обе проблемы — SQL код «долгих» миграций будет храниться отдельно от C#-кода и сам запрос будет выполняться не целиком, а отдельными порциями. Кроме того, все изменения делаются в собственном проекте, а не в исходных кодах мигратора.

Итак, создаем абстрактный класс, реализующий интерфейс IMigration:

public abstract class AbstractSQLMigration : IMigration
{
    private const string QUERY_PACK_DELIMITER = "GO\r\n";

    public void Up()
    {
        var query = GetQuery();                       1
        var subQueries = query.Split(                 2
            new[] {QUERY_PACK_DELIMITER}, 
            StringSplitOptions.RemoveEmptyEntries);
        Database.Logger.Log("Subqueries count: {0}", subQueries.Length);

        foreach (var subQuery in subQueries)
        {
            Database.BeginTransaction();              3
            Database.ExecuteNonQuery(subQuery.Replace(QUERY_PACK_DELIMITER, string.Empty));
            Database.Commit();
        }
    }

    protected abstract string GetQuery();

    public virtual void AfterUp()
    {
    }

    public virtual void Down()
    {
    }

    public virtual void AfterDown()
    {
    }

    public virtual void InitializeOnce(string[] args)
    {
    }

    public string Name
    {
        get { return StringUtils.ToHumanName(GetType().Name); }
    }

    public ITransformationProvider Database
    {
        get; set;
    }
}

В методе Up получается строка SQL-скрипта 1 (реализуется в потомках), затем этот скрипт разделяется на части 2. Разделителем служит ключевое слово GO (GO по сути не является ключевым словом SQL, а применяется в SQL Server Management Studio именно для разделения запроса на атомарные части, так что подобное применение нами GO вполне оправдано). Затем каждый из подзапросов выполняется в отдельной транзакции 3, благодаря этому можно избежать проблем с таймаутом.

Далее создаем наследника нашего класса, который будет получать текст SQL запроса из файла:

public class FileSQLMigration : AbstractSQLMigration
{
    private const string SQL_FOLDER_PATH = "SQL";

    private const string SQL_FILE_NAME_PATTERN = "{0}_{1}.sql";

    protected virtual string FileName
    {
        get
        {
            return null;
        }
    }

    protected override string GetQuery()         4
    {
        var path = GetSqlFilePath();
        if (!File.Exists(path))
        {
            throw new Exception(string.Format("File {0} not found!", path));
        }

        return File.ReadAllText(path);
    }

    private string GetSqlFilePath()              5
    {
        if (!string.IsNullOrEmpty(FileName))
        {
            return FileName;
        }

        return GetDefaultFileName();
    }

    private string GetDefaultFileName()
    {
        var version = GetType()
            .GetCustomAttributes(typeof(MigrationAttribute), true)
            .Cast<MigrationAttribute>()
            .First()
            .Version;

        var fileName = string.Format(SQL_FILE_NAME_PATTERN, version, GetType().Name);
        var relativePath = Path.Combine(SQL_FOLDER_PATH, fileName);
        var absolutePath = Path.Combine(GetCurrentDirectory(), relativePath);
        return absolutePath;
    }

    private string GetCurrentDirectory()
    {
        var assembly = Assembly.GetAssembly(GetType());
        var path = new Uri(assembly.CodeBase).LocalPath;
        return Path.GetDirectoryName(path);
    }
}

Основное здесь — реализация метода GetQuery 4, который читает указанный файл и возвращает его содержимое в качестве SQL-запроса миграции. Путь к файлу 5 может быть указан в дочерней миграции, в противном случае он будет сконструирован из версии и имени миграции (предполагается, что все скрипты находятся в папке SQL в корне проекта и копируются в output при сборке).

Приведенный набор классов уже можно использовать. Например, можно написать такую миграцию:

[Migration(20100407)]
public class InitialSchema : FileSQLMigration
{
    public override void Down()
    {
    }
}

Эта миграция будет просто выполнять скрипт из файла SQL/20100407_InitialSchema.sql

Часто миграции, выполняющие запросы используются для заполнения таблиц. Для этих целей можно создать еще одну реализацию миграции:

public class FillTableMigration : FileSQLMigration
{
    private readonly string _tableName;

    public FillTableMigration(string tableName)
    {
        _tableName = tableName;
    }

    public override void Down()
    {
        Database.Delete(_tableName, "1", "1");
    }
}

Миграции, унаследованные от FillTableMigration должны будут передать в конструктор имя таблицы, и эта таблица будет автоматически «очищена» при откате миграции.

Progg it