Миграции в Entity Framework (EF) представляют собой строго типизированный подход для выполнения распространенных операций, таких как создание, изменение и удаление таблиц, столбцов, индексов, и т.д. Однако реализация базовых операций достаточно ограничена и не поддерживает весь спектр параметров, которые поддерживает та или иная СУБД.
До EF 6, единственным способом обхода данного ограничения было использование операции Sql
, которая позволяет выполнить произвольную команду SQL при выполнении миграции. В EF 6 также появилась возможность реализации пользовательских строго типизированных операций.
Создание собственных операций
Базовая реализация операции CreateIndex
позволяет задать список колонок, по которым строится индекс, а также позволяет указать является ли индекс уникальным и/или кластерным. Однако, например, команда CREATE INDEX
Microsoft SQL Server поддерживает так же указание направлений сортировки, задание списка включенных колонок, параметров хранения и других дополнительных ограничений.
Рассмотрим реализацию данной расширенной операции миграции.
Для начала создадим класс ExtendedCreateIndexOperation
, который наследуется от абстрактного класса MigrationOperation
и переопределим свойство IsDestructiveChange, которое указывает, может ли наша операция привести к потере данных.
Начнем с возможности поддержки указания направлений сортировки по колонкам. Для этого создадим вспомогательный класс IndexColumnModel, который будет в себе содержать информацию о названии колонки и направление сортировки по ней.
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; }
}
}
Далее, добавим коллекцию строк, которая будет содержать список имен колонок таблицы, которые необходимо включить в индекс, а также другие необходимые параметры.
В результате получим следующий класс:
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
, передавать ему необходимые значения параметров и добавлять его в список операций текущей миграции.
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.
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);
}
}
}
}
Как видите, создание операций позволяет также добавить в них дополнительную логику проверок (в данном случае пропускается генерация кода для включенных колонок, в случае, если индекс является кластерным).
Итак, все необходимые приготовления завершены, и теперь необходимо подключить наш генератор.
Его можно зарегистрировать в соответствующих настройках миграций:
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
приложения:
namespace CustomMigrations.Migrations
{
using System.Data.Entity;
using System.Data.Entity.SqlServer;
public class CustomDbConfiguration : DbConfiguration
{
public CustomDbConfiguration()
{
SetMigrationSqlGenerator(SqlProviderServices.ProviderInvariantName,
() => new CustomSqlServerMigrationSqlGenerator());
}
}
}
Небольшие улучшения
Для того чтобы удобнее было добавлять колонки без явного создания экземпляров класса IndexColumnModel
добавим несколько методов расширения для строк, а также оператор неявного преобразования типов в сам класс.
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};
}
}
}
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