Создание пользовательских миграционных операций в Entity Framework 6

в 11:46, , рубрики: .net, Custom Migration Operation

Миграции в Entity Framework (EF) представляют собой строго типизированный подход для выполнения распространенных операций, таких как создание, изменение и удаление таблиц, столбцов, индексов, и т.д. Однако реализация базовых операций достаточно ограничена и не поддерживает весь спектр параметров, которые поддерживает та или иная СУБД.

До EF 6, единственным способом обхода данного ограничения было использование операции Sql, которая позволяет выполнить произвольную команду SQL при выполнении миграции. В EF 6 также появилась возможность реализации пользовательских строго типизированных операций.

Создание собственных операций

Базовая реализация операции CreateIndex позволяет задать список колонок, по которым строится индекс, а также позволяет указать является ли индекс уникальным и/или кластерным. Однако, например, команда CREATE INDEX Microsoft SQL Server поддерживает так же указание направлений сортировки, задание списка включенных колонок, параметров хранения и других дополнительных ограничений.

Рассмотрим реализацию данной расширенной операции миграции.

Для начала создадим класс ExtendedCreateIndexOperation, который наследуется от абстрактного класса MigrationOperation и переопределим свойство IsDestructiveChange, которое указывает, может ли наша операция привести к потере данных.

Начнем с возможности поддержки указания направлений сортировки по колонкам. Для этого создадим вспомогательный класс IndexColumnModel, который будет в себе содержать информацию о названии колонки и направление сортировки по ней.

IndexColumnModel.cs

namespace CustomMigrations.Infrastructure.Migrations.Models
{
    using System;

    /// <summary>
    ///     Index column sort direction.
    /// </summary>
    internal enum SortDirection
    {
        Ascending,
        Descending
    }

    /// <summary>
    ///     Represents information about an index column.
    /// </summary>
    internal class IndexColumnModel
    {
        /// <summary>
        ///     Gets or sets the name of the column.
        /// </summary>
        /// <value>
        ///     The name of the column.
        /// </value>
        public string Name { get; set; }

        /// <summary>
        ///     Gets or sets the sort direction.
        /// </summary>
        /// <value>
        ///     The sort direction.
        /// </value>
        public SortDirection SortDirection { get; set; }
    }
}

Далее, добавим коллекцию строк, которая будет содержать список имен колонок таблицы, которые необходимо включить в индекс, а также другие необходимые параметры.

В результате получим следующий класс:

ExtendedCreateIndexOperation.cs

namespace CustomMigrations.Infrastructure.Migrations.Operations
{
    using System;
    using System.Collections.Generic;
    using System.Data.Entity.Migrations.Model;
    using System.Linq;
    using Models;

    /// <summary>
    ///     Represents creating an extended database index.
    /// </summary>
    internal class ExtendedCreateIndexOperation : MigrationOperation
    {
        private readonly ICollection<IndexColumnModel> _columns = new List<IndexColumnModel>();
        private readonly ICollection<string> _includes = new List<string>();
        private string _name;
        private string _table;

        /// <summary>
        ///     Initializes a new instance of the <see cref="ExtendedCreateIndexOperation" /> class.
        /// </summary>
        /// <param name="anonymousArguments">
        ///     Use anonymous type syntax to specify arguments e.g. 'new { SampleArgument = "MyValue" }'.
        /// </param>
        public ExtendedCreateIndexOperation(object anonymousArguments = null)
            : base(anonymousArguments)
        {
        }

        /// <summary>
        ///     Gets the columns collection.
        /// </summary>
        /// <value>
        ///     The columns collection.
        /// </value>
        public ICollection<IndexColumnModel> Columns
        {
            get { return _columns; }
        }

        /// <summary>
        ///     Gets the non-key columns to be added to the leaf level of the nonclustered index.
        /// </summary>
        /// <value>
        ///     The the non-key columns to be added to the leaf level of the nonclustered index.
        /// </value>
        public ICollection<string> Includes
        {
            get { return _includes; }
        }

        /// <summary>
        ///     Gets or sets the name of the table.
        /// </summary>
        /// <value>
        ///     The name of the table.
        /// </value>
        /// <exception cref="System.ArgumentException">Table name is null or whitespace.;value</exception>
        public string Table
        {
            get { return _table; }
            set
            {
                if (string.IsNullOrWhiteSpace(value))
                {
                    throw new ArgumentException("Table name is null or whitespace.", "value");
                }
                _table = value;
            }
        }

        /// <summary>
        ///     Gets or sets the name of the index.
        /// </summary>
        /// <value>
        ///     The name of the index.
        /// </value>
        public string Name
        {
            get
            {
                return _name ??
                       IndexOperation.BuildDefaultName(
                           _columns.Where(c => c != null && !string.IsNullOrWhiteSpace(c.Name)).Select(c => c.Name));
            }
            set { _name = value; }
        }

        /// <summary>
        ///     Gets or sets a value indicating if this is a unique index.
        /// </summary>
        /// <value>
        ///     The value indicating if this is a unique index.
        /// </value>
        public bool IsUnique { get; set; }

        /// <summary>
        ///     Gets or sets whether this is a clustered index.
        /// </summary>
        /// <value>
        ///     Whether this is a clustered index.
        /// </value>
        public bool IsClustered { get; set; }

        /// <summary>
        ///     Gets or sets the WHERE option (<filter_predicate>).
        /// </summary>
        /// <value>
        ///     The WHERE option.
        /// </value>
        public string Where { get; set; }

        /// <summary>
        ///     Gets or sets the WITH option (<relational_index_option> [ ,...n ]).
        /// </summary>
        /// <value>
        ///     The WITH option.
        /// </value>
        public string With { get; set; }

        /// <summary>
        ///     Gets or sets the ON option (partition_scheme_name ( column_name ) | filegroup_name | default).
        /// </summary>
        /// <value>
        ///     The ON option.
        /// </value>
        public string On { get; set; }

        /// <summary>
        ///     Gets or sets the FILESTREAM_ON option (filestream_filegroup_name | partition_scheme_name | "NULL").
        /// </summary>
        /// <value>
        ///     The FILESTREAM_ON option.
        /// </value>
        public string FileStreamOn { get; set; }

        public override bool IsDestructiveChange
        {
            get { return false; }
        }
    }
}

Подключение операций

Хорошо, операция создана, но необходимо добавить возможность ее использования.

Для этого добавим метод расширения CreateIndex, который будет создавать экземпляр класса ExtendedCreateIndexOperation, передавать ему необходимые значения параметров и добавлять его в список операций текущей миграции.

DbMigrationExtensions.cs

namespace CustomMigrations.Infrastructure.Extensions
{
    using System;
    using System.Collections.Generic;
    using System.Data.Entity.Migrations;
    using System.Data.Entity.Migrations.Infrastructure;
    using System.Linq;
    using Migrations.Models;
    using Migrations.Operations;

    internal static class DbMigrationExtensions
    {
        /// <summary>
        ///     Adds an operation to create an table index.
        /// </summary>
        /// <param name="migration">The database migration instance.</param>
        /// <param name="table">
        ///     The name of the table to create the index on. Schema name is optional, if no schema is specified
        ///     then dbo is assumed.
        /// </param>
        /// <param name="columns">The name of the columns to create the index on.</param>
        /// <param name="includes">The includes.</param>
        /// <param name="unique">
        ///     A value indicating if this is a unique index. If no value is supplied a non-unique index will be
        ///     created.
        /// </param>
        /// <param name="name">
        ///     The name to use for the index in the database. If no value is supplied a unique name will be
        ///     generated.
        /// </param>
        /// <param name="clustered">A value indicating whether or not this is a clustered index.</param>
        /// <param name="where">The WHERE option (<filter_predicate>).</param>
        /// <param name="with">The WITH option (<relational_index_option> [ ,...n ]).</param>
        /// <param name="on">The ON option (partition_scheme_name ( column_name ) | filegroup_name | default).</param>
        /// <param name="fileStreamOn">The FILESTREAM_ON option (filestream_filegroup_name | partition_scheme_name | "NULL").</param>
        /// <param name="anonymousArguments">
        ///     Additional arguments that may be processed by providers. Use anonymous type syntax to
        ///     specify arguments e.g. 'new { SampleArgument = "MyValue" }'.
        /// </param>
        /// <exception cref="System.ArgumentNullException">
        ///     migration
        ///     or
        ///     columns
        /// </exception>
        /// <exception cref="System.ArgumentException">
        ///     Table name is null or whitespace.;table
        ///     or
        ///     Columns collection is empty.;columns
        /// </exception>
        public static void CreateIndex(this DbMigration migration,
            string table,
            ICollection<IndexColumnModel> columns,
            ICollection<string> includes = null,
            bool unique = false,
            string name = null,
            bool clustered = false,
            string where = null,
            string with = null,
            string on = null,
            string fileStreamOn = null,
            object anonymousArguments = null)
        {
            if (migration == null)
            {
                throw new ArgumentNullException("migration");
            }

            if (string.IsNullOrWhiteSpace(table))
            {
                throw new ArgumentException("Table name is null or whitespace.", "table");
            }

            if (columns == null)
            {
                throw new ArgumentNullException("columns");
            }

            if (!columns.Any())
            {
                throw new ArgumentException("Columns collection is empty.", "columns");
            }

            var createIndexOperation = new ExtendedCreateIndexOperation(anonymousArguments)
            {
                Table = table,
                IsUnique = unique,
                Name = name,
                IsClustered = clustered,
                Where = where,
                With = with,
                On = on,
                FileStreamOn = fileStreamOn
            };

            foreach (IndexColumnModel column in columns)
            {
                createIndexOperation.Columns.Add(column);
            }

            if (includes != null)
            {
                foreach (string column in includes)
                {
                    createIndexOperation.Includes.Add(column);
                }
            }

            ((IDbMigration) migration).AddOperation(createIndexOperation);
        }
    }
}

Генерация SQL кода

Для того чтобы научить EF преобразовывать пользовательские операции в SQL код, необходимо расширить возможности имеющегося базового генератора.

Для этого необходимо создать наследника от класса SqlServerMigrationSqlGeneratorи и переопределить метод Generate(MigrationOperation). Данный метод вызывается только при обработке операций, которые неизвестны базовому генератору SQL.

Опишем необходимые преобразования из нашей операции в соответствующее выражение на языке SQL.

CustomSqlServerMigrationSqlGenerator.cs

namespace CustomMigrations.Infrastructure.Migrations
{
    using System;
    using System.Collections.Generic;
    using System.Data.Entity.Migrations.Model;
    using System.Data.Entity.Migrations.Utilities;
    using System.Data.Entity.SqlServer;
    using System.Linq;
    using Models;
    using Operations;

    /// <summary>
    ///     Custom provider to convert provider agnostic migration operations into SQL commands
    ///     that can be run against a Microsoft SQL Server database.
    /// </summary>
    internal sealed class CustomSqlServerMigrationSqlGenerator : SqlServerMigrationSqlGenerator
    {
        private static readonly IDictionary<SortDirection, string> SortDirectionDescriptionMap = new Dictionary
            <SortDirection, string>
        {
            {SortDirection.Ascending, "ASC"},
            {SortDirection.Descending, "DESC"}
        };

        /// <summary>
        ///     Generates SQL for a <see cref="T:System.Data.Entity.Migrations.Model.MigrationOperation" />.
        ///     Allows derived providers to handle additional operation types.
        ///     Generated SQL should be added using the Statement method.
        /// </summary>
        /// <param name="migrationOperation">The operation to produce SQL for.</param>
        /// <exception cref="System.ArgumentNullException">migrationOperation</exception>
        protected override void Generate(MigrationOperation migrationOperation)
        {
            if (migrationOperation == null)
            {
                throw new ArgumentNullException("migrationOperation");
            }

            var createIndexOperation = migrationOperation as ExtendedCreateIndexOperation;
            if (createIndexOperation == null)
            {
                return;
            }

            Generate(createIndexOperation);
        }

        /// <summary>
        ///     Generates SQL for a
        ///     <see cref="T:CustomMigrations.Infrastructure.Migrations.Operations.ExtendedCreateIndexOperation" />.
        /// </summary>
        /// <param name="createIndexOperation">The operation to produce SQL for.</param>
        /// <exception cref="System.ArgumentNullException">createIndexOperation</exception>
        private void Generate(ExtendedCreateIndexOperation createIndexOperation)
        {
            if (createIndexOperation == null)
            {
                throw new ArgumentNullException("createIndexOperation");
            }

            using (IndentedTextWriter writer = Writer())
            {
                writer.Write("CREATE ");

                if (createIndexOperation.IsUnique)
                {
                    writer.Write("UNIQUE ");
                }

                if (createIndexOperation.IsClustered)
                {
                    writer.Write("CLUSTERED ");
                }

                writer.Write("INDEX ");
                writer.WriteLine(Quote(createIndexOperation.Name));
                writer.Indent++;
                writer.Write("ON ");
                writer.Write(Name(createIndexOperation.Table));

                writer.Write("(");
                writer.Write(string.Join(", ",
                    createIndexOperation.Columns.Where(c => c != null && !string.IsNullOrWhiteSpace(c.Name))
                        .Select(c => Quote(c.Name) + " " + SortDirectionDescriptionMap[c.SortDirection])));
                writer.Write(")");

                // Skip the INCLUDE part for clustered indexes.
                if (!createIndexOperation.IsClustered && createIndexOperation.Includes.Count > 0)
                {
                    writer.WriteLine();
                    writer.Write("INCLUDE (");
                    writer.Write(string.Join(", ",
                        createIndexOperation.Includes.Where(c => !string.IsNullOrWhiteSpace(c)).Select(Quote)));
                    writer.Write(")");
                }

                if (!string.IsNullOrWhiteSpace(createIndexOperation.Where))
                {
                    writer.WriteLine();
                    writer.Write("WHERE ");
                    writer.Write(createIndexOperation.Where);
                }

                if (!string.IsNullOrWhiteSpace(createIndexOperation.With))
                {
                    writer.WriteLine();
                    writer.Write("WITH (");
                    writer.Write(createIndexOperation.With);
                    writer.Write(")");
                }

                if (!string.IsNullOrWhiteSpace(createIndexOperation.On))
                {
                    writer.WriteLine();
                    writer.Write("ON ");
                    writer.Write(createIndexOperation.On);
                }

                if (!string.IsNullOrWhiteSpace(createIndexOperation.FileStreamOn))
                {
                    writer.WriteLine();
                    writer.Write("FILESTREAM_ON ");
                    writer.Write(createIndexOperation.On);
                }

                Statement(writer);
            }
        }
    }
}

Как видите, создание операций позволяет также добавить в них дополнительную логику проверок (в данном случае пропускается генерация кода для включенных колонок, в случае, если индекс является кластерным).

Итак, все необходимые приготовления завершены, и теперь необходимо подключить наш генератор.

Его можно зарегистрировать в соответствующих настройках миграций:

Configuration.cs

namespace CustomMigrations.Migrations
{
    using System.Data.Entity;
    using System.Data.Entity.Migrations;
    using System.Data.Entity.SqlServer;

    internal sealed class Configuration : DbMigrationsConfiguration<DbContext>
    {
        public Configuration()
        {
            SetSqlGenerator(SqlProviderServices.ProviderInvariantName, new CustomSqlServerMigrationSqlGenerator());
        }
    }
}

Или в настройках DbConfigration приложения:

CustomDbConfiguration.cs

namespace CustomMigrations.Migrations
{
    using System.Data.Entity;
    using System.Data.Entity.SqlServer;

    public class CustomDbConfiguration : DbConfiguration
    {
        public CustomDbConfiguration()
        {
            SetMigrationSqlGenerator(SqlProviderServices.ProviderInvariantName,
                () => new CustomSqlServerMigrationSqlGenerator());
        }
    }
}

Небольшие улучшения

Для того чтобы удобнее было добавлять колонки без явного создания экземпляров класса IndexColumnModel добавим несколько методов расширения для строк, а также оператор неявного преобразования типов в сам класс.

StringExtensions.cs

namespace CustomMigrations.Infrastructure.Extensions
{
    using System;
    using Migrations.Models;

    internal static class StringExtensions
    {
        /// <summary>
        ///     Creates the index column model with the ascending sort direction.
        /// </summary>
        /// <param name="value">The column name.</param>
        /// <returns>The index column model.</returns>
        /// <exception cref="System.ArgumentNullException">value</exception>
        public static IndexColumnModel Ascending(this string value)
        {
            if (value == null)
            {
                throw new ArgumentNullException("value");
            }

            return new IndexColumnModel {Name = value, SortDirection = SortDirection.Ascending};
        }

        /// <summary>
        ///     Creates the index column model with the descending sort direction.
        /// </summary>
        /// <param name="value">The column name.</param>
        /// <returns>The index column model.</returns>
        /// <exception cref="System.ArgumentNullException">value</exception>
        public static IndexColumnModel Descending(this string value)
        {
            if (value == null)
            {
                throw new ArgumentNullException("value");
            }

            return new IndexColumnModel {Name = value, SortDirection = SortDirection.Descending};
        }
    }
}

IndexColumnModel.cs

namespace CustomMigrations.Infrastructure.Migrations.Models
{
    using System;

    /// <summary>
    ///     Index column sort direction.
    /// </summary>
    internal enum SortDirection
    {
        Ascending,
        Descending
    }

    /// <summary>
    ///     Represents information about an index column.
    /// </summary>
    internal class IndexColumnModel
    {
        /// <summary>
        ///     Gets or sets the name of the column.
        /// </summary>
        /// <value>
        ///     The name of the column.
        /// </value>
        public string Name { get; set; }

        /// <summary>
        ///     Gets or sets the sort direction.
        /// </summary>
        /// <value>
        ///     The sort direction.
        /// </value>
        public SortDirection SortDirection { get; set; }

        /// <summary>
        ///     Performs an implicit conversion from <see cref="System.String" /> to <see cref="IndexColumnModel" />.
        /// </summary>
        /// <param name="value">The value.</param>
        /// <returns>
        ///     The result of the conversion.
        /// </returns>
        /// <exception cref="System.ArgumentNullException">value</exception>
        public static implicit operator IndexColumnModel(string value)
        {
            if (value == null)
            {
                throw new ArgumentNullException("value");
            }
            return new IndexColumnModel {Name = value, SortDirection = SortDirection.Ascending};
        }
    }
}

Проверка результатов работы

Для иллюстрации результатов работы сравним создание индекса с параметрами по старинке и с использованием нашей операции.

Было:

...

Sql("CREATE NONCLUSTERED INDEX [IX_Column1_Column2_Column3] ON [dbo].[TestTable]" +
    "(" +
    "    [Column1] DESC," +
    "    [Column2] ASC," +
    "    [Column3] ASC" +
    ")" +
    " INCLUDE (" +
    "    [Column4]," +
    "    [Column5]" +
    ")" +
    " WHERE [Column6] = 'Some filter'" +
    " WITH (SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF) ON [PRIMARY]");

...

Стало:

...

this.CreateIndex("dbo.TestTable",
    columns: new[] {"Column1".Descending(), "Column2".Ascending(), "Column3"},
    includes: new[] {"Column4", "Column5"},
    where: "[Column6] = 'Some filter'",
    with: "SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF",
    on: "[PRIMARY]"
    );

...

Результат работы операции можно оценить, выполнив команду Update-Database -Script в консоли Package Manager Console.

...

CREATE INDEX [IX_Column1_Column2_Column3]
    ON [dbo].[TestTable]([Column1] DESC, [Column2] ASC, [Column3] ASC)
    INCLUDE ([Column4], [Column5])
    WHERE [Column6] = 'Some filter'
    WITH (SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF)
    ON [PRIMARY]

...

Полный исходный код доступен здесь.

Автор: sndr

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js