image

По роду моей деятельности, мне часто приходится делать различные небольшие проекты, в основном, это сайты написанные на ASP.NET MVC. В любом современном проекте присутствуют данные, а значит и база данных, а значит с ней нужно как то работать.
Если отбросить все дискуссии про «за и против», то спешу сообщить, что мой выбор пал на Entity Framework Code First. Во время разработки проекта, я уделяю внимание исключительно бизнес-логике и не трачу время на проектирование базы данных и прочие шаблонные действия. Неприятным сюрпризом при использовании такого подхода для меня стало отсутствие возможности «из коробки» у Entity Framework возможности строить индекс по полям, а так же пользоваться удобным и современным механизмом полнотекстового поиска.

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

Основным требованием к решению проблемы, является простота интеграции в любой новый (существующий) проект. В Code First принято все настраивать атрибутами, поэтому хорошо было бы сделать так:

public class SomeClass
  {
    public int Id { get; set; }

    [Index]
    public string Name { get; set; }

    [FullTextIndex]
    public string Description { get; set; }
  }

при этом, не хотелось бы переопределять DatabaseInitializer и делать прочие нетривиальные действия.

В своей работе я использую Visual Studio 2013 Ultimate. Создадим новый проект типа Class Library, сразу добавим в него Entity Framework 6 Beta 1 с помощью NuGet консоли (Package Manager Console):

PM> Install-Package EntityFramework -Pre

Создадим атрибуты Index и FullTextSearch, а так же перечисление для FullTextSearch:
public class IndexAttribute : Attribute { }
public class FullTextIndexAttribute : Attribute { }
public class FullTextIndex
 {
    public enum SearchAlgorithm
     {
        Contains,
        FreeText
     }
 }

Если Вы ранее работали с полнотекстовым поиском, то Вы наверняка поняли зачем нужен Contains и FreeText, если нет, то Вам сюда.
Далее, создадим абстрактный класс, унаследованный от DbContext:
public abstract class DbContextIndexed : DbContext
 {
    private static bool Complete;
    private int? language;

    public int Language
     {
        get
         {
          return language.HasValue ? language.Value : 1049; //1049 - русский язык
         }
       set
        {
         language = value;
        }
     }

    protected override void Dispose(bool disposing)
     {
      if (!Complete)
       {
        Complete = true;
        CalculateIndexes();
       }
       base.Dispose(disposing);
     }

    private void CalculateIndexes()
     {
      if (GetCompleteFlag()) return;

      //Получаем все сущности текущего DbContext
      foreach (var property in this.GetType()
                     .GetProperties()
                     .Where(f => f.PropertyType.BaseType != null && f.PropertyType.BaseType.Name == "DbQuery`1"))
       {
        var currentEntityType = property.PropertyType.GetGenericArguments().FirstOrDefault();
        if (currentEntityType == null || currentEntityType.BaseType.FullName != "System.Object")
            continue;

        //Получаем название таблицы в БД
        var tableAttribute = currentEntityType
                       .GetCustomAttributes(typeof(TableAttribute), false).FirstOrDefault() as TableAttribute;
        var tableName = tableAttribute != null ? tableAttribute.Name : property.Name;

        //Получаем у сущности свойства помеченые аттрибутом Index, создаем по ним индекс
        BuildingIndexes(tableName, currentEntityType.GetProperties()
                        .Where(f => f.GetCustomAttributes(typeof(IndexAttribute), false)
                        .Any()));

        //Получаем у сущности свойства помеченые атрибутом FullTextIndex, создаем по ним индекс
        BuildingFullTextIndexes(tableName, currentEntityType.GetProperties()
                        .Where(f => f.GetCustomAttributes(typeof(FullTextIndexAttribute), false)
                        .Any()));
     }
     CreateCompleteFlag();
    }

    private void BuildingIndexes(string tableName, IEnumerable<PropertyInfo> propertyes)
     {
     foreach (var property in propertyes)
         Database.ExecuteSqlCommand(String.Format("CREATE INDEX IX_{0} ON {1} ({0})", property.Name, tableName));
     }

    private void BuildingFullTextIndexes(string tableName, IEnumerable<PropertyInfo> propertyes)
     {
     var fullTextColumns = string.Empty;
     foreach (var property in propertyes)
           fullTextColumns += String.Format("{0}{1} language {2}", 
                          (string.IsNullOrWhiteSpace(fullTextColumns) ? null : ","),
                          property.Name, Language);

     //Создаем полнотекстовый индекс
     Database.ExecuteSqlCommand(System.Data.Entity.TransactionalBehavior.DoNotEnsureTransaction,
               String.Format("IF NOT EXISTS (SELECT * FROM sysindexes WHERE id=object_id('{1}') and name='IX_{2}') 
                                          CREATE UNIQUE INDEX IX_{2} ON {1} ({2});
                                          CREATE FULLTEXT CATALOG FTXC_{1} AS DEFAULT;
                                          CREATE FULLTEXT INDEX ON {1}({0}) KEY INDEX [IX_{2}] ON [FTXC_{1}]", 
                                          fullTextColumns, tableName, "Id"));
     }

    private void CreateCompleteFlag()
     {
     Database.ExecuteSqlCommand(System.Data.Entity.TransactionalBehavior.DoNotEnsureTransaction, 
                      "CREATE TABLE [dbo].[__IndexBuildingHistory](
                       [DataContext] [nvarchar](255) NOT NULL, 
                       [Complete] [bit] NOT NULL, 
                       CONSTRAINT [PK___IndexBuildingHistory] 
                       PRIMARY KEY CLUSTERED ([DataContext] ASC))");
     }

    private bool GetCompleteFlag()
     {
     var queryResult = Database.SqlQuery(typeof(string), 
            "IF OBJECT_ID('__IndexBuildingHistory', 'U') IS NOT NULL SELECT 
             'True' AS 'Result' ELSE SELECT 'False' AS 'Result'")
            .GetEnumerator();
     queryResult.MoveNext();
     return bool.Parse(queryResult.Current as string);
     }
 }

чтобы не раздувать пост, здесь намеренно убраны summary и некоторые комментарии, полная версия на GitHub'e. Если кратко пояснить, то EF создает модель при первичном обращении к DbContext'у, соответственно строить индексы на конструкторе мы не можем, остается самый простой вариант построить их посл�� создания модели, при попытке уничтожить экземпляр DbContext. Далее, чтобы не нагружать БД каждый раз несколькими запросами и попыткой создания, в лучших традициях EF создадим в базе служебную таблицу __IndexBuildingHistory, наличие которой, будет свидетельствовать о наличии индексов. Остальное очевидно.

В целом, если уже сейчас создать модель, пометить ее атрибутами и запустить проект, то индексы будут успешно созданы, однако, нам еще нужно удобное использование полнотекстового индекса, для это создадим класс расширение (extension class):
public static class IQueryableExtension
 {
    public static IQueryable<T> FullTextSearch<T>(this DbSet<T> queryable,
                           Expression<Func<T, bool>> func,
                           FullTextIndex.SearchAlgorithm algorithm = FullTextIndex.SearchAlgorithm.FreeText) 
                           where T : class
    {
    var internalSet = queryable.AsQueryable()
                           .GetType()
                           .GetProperty("System.Data.Entity.Internal.Linq.IInternalSetAdapter.InternalSet", 
                                    BindingFlags.NonPublic | BindingFlags.Instance)
                           .GetValue(queryable.AsQueryable());
    var entitySet = (EntitySet)internalSet.GetType()
                           .GetProperty("EntitySet")
                           .GetValue(internalSet);

    var searchType = algorithm == FullTextIndex.SearchAlgorithm.Contains ? 
                                                           "CONTAINS" :
                                                           "FREETEXT";
    var columnName = ((MemberExpression)((BinaryExpression)func.Body).Left).Member.Name;
    var searchPattern = ((ConstantExpression)((BinaryExpression)func.Body).Right).Value;

    return queryable.SqlQuery(
                String.Format("SELECT * FROM {0} WHERE {1};", 
                entitySet.Name, 
                String.Format("{0}({1},'{2}')", 
                    searchType, 
                    columnName, 
                    searchPattern)))
                .AsQueryable();
    }
 }

Вот и все, казалось бы, такая популярная проблема как индексы и полнотекстовый поиск требует особого внимания со стороны создателей Entity Framework, однако, простого решения на сегодняшний день не было. Данная реализация с лихвой перекрывает мои требования к индексации, если Вам чего то не хватает (обработки ошибок, настроек — например, список стоп-слов и т.д.), Вы можете самостоятельно забрать проект с GitHub'a и доработать, либо написать мне. Статья была бы совсем скучной, если бы мы не попробовали как все это работает, поэтому переходим к использованию.

Использование

1. Создадим проект Console application
2. Добавим Entity Framework 6 beta через NuGet
3. Добавим ссылку на библиотеку (если Вы не читали про реализацию, то Вы можете скачать готовую библиотеку, ссылки в конце статьи)
4. Создадим простую сущность, без вложеностей и связей, для примера этого достаточно:
public class Animal
 {
    public int Id { get; set; }

    [Index]
    [StringLength(200)]
    public string Name { get; set; }

    [FullTextIndex]
    public string Description { get; set; }

    public int Family { get; set; }

    [FullTextIndex]
    public string AdditionalDescription { get; set; }
 }

Сущность животное, с названием (Name), по которому мы построим обычный индекс, описанием (Description) — построим полнотекстовый индекс и прочими полями для вида, мы не будем их использовать. Обратите внимание на строку [StringLength(200)], при создании индекса по строковым полям она обязательна, т.к. MSSQL позволяет строить индексы по полям, размер которых не превышает 900 байт — сколько это в символах, зависит от выбранной Вами кодировки базы данных.

5. Создадим контекст базы данных:
public class DataContext : DbContextIndexed
 {
    public DbSet<Animal> Animals { get; set; }
 }

единственная разница здесь в наследовании, обычно Вы наследуетесь от DbContext, а теперь от нашей DbContextIndexed

6. В Programm.cs добавим обращение к контексту, чтобы спровоцировать создание базы данных:
static void Main(string[] args)
 {
    using (var context = new DataContext())
     {
          var temp = context.Animals.ToList();
     }
 }

7. В config файле проекта пропишите строку подключения к базе данных с названием DataContext:
<configuration>
    <connectionStrings>
        <add name="DataContext" connectionString="Data Source=(local)\SQL; Initial Catalog=EFCF; 
         Integrated Security=true;" providerName="System.Data.SqlClient"/>
    </connectionStrings>
</configuration>


8. Нажимаем F5, чтобы создать базу данных, когда программа завершится, с помощью Managment Studio можно убедится, что все работает, как мы запланировали:
image

9. Теперь, давайте попробуем добавить данные, чтобы опробовать поиск:
using (var context = new DataContext())
 {
    context.Animals.Add(new Animal { 
        Name = "Кот", 
        Description = "Относится к семейству кошачьих, очень любят вискас." });
    context.Animals.Add(new Animal { 
        Name = "Лев", 
        Description = "Лев по праву считается королем зверей, самый известный среди котов." });
    context.Animals.Add(new Animal { 
        Name = "Пантера", 
        Description = "Пантера - это маленькая черная кошка." });
    context.Animals.Add(new Animal { 
        Name = "Тигр", 
        Description = "Большой полосатый кот." });
    context.Animals.Add(new Animal { 
        Name = "Питбуль", 
        Description = "Хорошая бойцовая собака." });
     context.Animals.Add(new Animal { 
        Name = "Американский стафардширский терьер", 
        Description = "Произошел от питбуля, является примером отличной бойцовой собаки." });
    context.SaveChanges();
 }

запустим, чтобы данные записались в БД, теперь попробуем поискать:
using (var context = new DataContext())
 {
    foreach (var pet in context.Animals.FullTextSearch(f => f.Description == "коты"))
         Console.WriteLine("{0} - {1}", pet.Name, pet.Description);
 }

результат следующий:
image

У меня установлена версия MSSQL 2008R2, поэтому результат хороший, но не идеальный. Насколько я знаю в 2013-ой версии мы бы еще получили значение пантера, т.к. «кошка», тоже бы учлось.

Я считаю, что довольно простым, и самое главное, «стандартным» способом можно пользоваться полнотекстовым поиском и строить индексы по полям. Данной реализации достаточно для 95% маленьких проектов, но я искренне надеюсь, что создатели Entity Framework все таки реализуют данный функционал «в коробке».

Источники

Скачать готовую библиотеку:
в формате zip
в формате dll
Проект выложен на GitHub
Entity Framework 6 beta на сайте Nuget