Entity Framework Code First — индексация полей и полнотекстовый поиск

в 7:54, , рубрики: .net, index, orm, метки: ,

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}]) WITH STOPLIST = SYSTEM;", 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 и некоторые комментарии, полная версия на GitHab'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, однако, простого решения на сегодняшний день не было. Данная реализация с лихвой перекрывает мои требования к индексации, если Вам чего то не хватает (обработки ошибок, настроек — например, список стоп-слов и т.д.), Вы можете самостоятельно забрать проект с GitHab'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:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup> 
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5"/>
</startup>
<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
Проект выложен на GitHab
Entity Framework 6 beta на сайте Nuget

Автор: Razario777

Источник

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


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