Eric
Eric

Reputation: 2047

Entity Framework, Code First and Full Text Search

I realize that a lot of questions have been asked relating to full text search and Entity Framework, but I hope this question is a bit different.

I am using Entity Framework, Code First and need to do a full text search. When I need to perform the full text search, I will typically have other criteria/restrictions as well - like skip the first 500 rows, or filter on another column, etc.

I see that this has been handled using table valued functions - see http://sqlblogcasts.com/blogs/simons/archive/2008/12/18/LINQ-to-SQL---Enabling-Fulltext-searching.aspx. And this seems like the right idea.

Unfortunately, table valued functions are not supported until Entity Framework 5.0 (and even then, I believe, they are not supported for Code First).

My real question is what are the suggestions for the best way to handle this, both for Entity Framework 4.3 and Entity Framework 5.0. But to be specific:

  1. Other than dynamic SQL (via System.Data.Entity.DbSet.SqlQuery, for example), are there any options available for Entity Framework 4.3?

  2. If I upgrade to Entity Framework 5.0, is there a way I can use table valued functions with code first?

Thanks, Eric

Upvotes: 56

Views: 43104

Answers (6)

Shervin Ivari
Shervin Ivari

Reputation: 2501

If you are using the SqlServer fulltext feature, you can configure it and use the EF default functions.

Configure using the raw query

  //invoke this function after applying your migrations
  public static async Task ApplyFullTextSearchIndex(this ApplicationDbContext context)
  {  
  //Default catalogs
  var catalogSql = @"
  IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ftCatalog')
  BEGIN
      CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;
  END";

  await context.Database.ExecuteSqlRawAsync(catalogSql);

  //Setting Indexes
  var sql = @"
      IF NOT EXISTS (SELECT * FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('dbo.MyTable'))
      BEGIN
          CREATE FULLTEXT INDEX ON Mytable(mycolumn1, mycolumn2)
          KEY INDEX my_index
          WITH CHANGE_TRACKING AUTO;
      END";

  await context.Database.ExecuteSqlRawAsync(sql);
}

You can also configure it using migrations

public partial class AddFullTextIndex : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;", suppressTransaction: true);
        
        migrationBuilder.Sql(
            "CREATE FULLTEXT INDEX ON YourTableName(yourcolumns) KEY INDEX YourPrimaryKeyIndex;",
            suppressTransaction: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DROP FULLTEXT INDEX ON YourTableName;", suppressTransaction: true);
        migrationBuilder.Sql("DROP FULLTEXT CATALOG ftCatalog;", suppressTransaction: true);
    }
}

After you configure it you can use it with Ef functions

query.Where(x => EF.Functions.FreeText(x.Title, search)

Upvotes: 1

akd
akd

Reputation: 6758

The example here http://www.entityframework.info/Home/FullTextSearch is not complete solution. You will need to look into understand how the full text search works. Imagine you have a search field and the user types 2 words to hit search. The above code will throw an exception. You need to do pre-processing on the search phrase first to pass it to the query by using logical AND or OR.

for example your search phrase is "blah blah2" then you need to convert this into:

var searchTerm = @"\"blah\" AND/OR \"blah2\" "; 

Complete solution would be:

 value = Regex.Replace(value, @"\s+", " "); //replace multiplespaces
                    value = Regex.Replace(value, @"[^a-zA-Z0-9 -]", "").Trim();//remove non-alphanumeric characters and trim spaces

                    if (value.Any(Char.IsWhiteSpace))
                    {
                        value = PreProcessSearchKey(value);
                    }


 public static string PreProcessSearchKey(string searchKey)
    {
        var splitedKeyWords = searchKey.Split(null); //split from whitespaces

        // string[] addDoubleQuotes = new string[splitedKeyWords.Length];

        for (int j = 0; j < splitedKeyWords.Length; j++)
        {
            splitedKeyWords[j] = $"\"{splitedKeyWords[j]}\"";
        }

        return string.Join(" AND ", splitedKeyWords);
    }

this methods uses AND logic operator. You might pass that as an argument and use the method for both AND or OR operators.

You must escape none-alphanumeric characters otherwise it would throw exception when a user enters alpha numeric characters and you have no server site model level validation in place.

Upvotes: 3

s.meijer
s.meijer

Reputation: 3909

As the other guys mentioned, I would say start using Lucene.NET

Lucene has a pretty high learning curve, but I found an wrapper for it called "SimpleLucene", that can be found on CodePlex

Let me quote a couple of codeblocks from the blog to show you how easy it is to use. I've just started to use it, but got the hang of it really fast.

First, get some entities from your repository, or in your case, use Entity Framework

public class Repository
{
    public IList<Product> Products {
        get {
            return new List<Product> {
                new Product { Id = 1, Name = "Football" },
                new Product { Id = 2, Name = "Coffee Cup"},
                new Product { Id = 3, Name = "Nike Trainers"},
                new Product { Id = 4, Name = "Apple iPod Nano"},
                new Product { Id = 5, Name = "Asus eeePC"},
            };
        }
    }
}

The next thing you want to do is create an index-definition

public class ProductIndexDefinition : IIndexDefinition<Product> {
    public Document Convert(Product p) {
        var document = new Document();
        document.Add(new Field("id", p.Id.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));
        document.Add(new Field("name", p.Name, Field.Store.YES, Field.Index.ANALYZED));
        return document;
    }

    public Term GetIndex(Product p) {
        return new Term("id", p.Id.ToString());
    }
}

and create an search index for it.

var writer = new DirectoryIndexWriter(
    new DirectoryInfo(@"c:\index"), true);

var service = new IndexService();
service.IndexEntities(writer, Repository().Products, ProductIndexDefinition());

So, you now have an search-able index. The only remaining thing to do is.., searching! You can do pretty amazing things, but it can be as easy as this: (for greater examples see the blog or the documentation on codeplex)

var searcher = new DirectoryIndexSearcher(
                new DirectoryInfo(@"c:\index"), true);

var query = new TermQuery(new Term("name", "Football"));

var searchService = new SearchService();

Func<Document, ProductSearchResult> converter = (doc) => {
    return new ProductSearchResult {
        Id = int.Parse(doc.GetValues("id")[0]),
        Name = doc.GetValues("name")[0]
    };
};

IList<Product> results = searchService.SearchIndex(searcher, query, converter);

Upvotes: 6

Ben
Ben

Reputation: 2484

Using interceptors introduced in EF6, you could mark the full text search in linq and then replace it in dbcommand as described in http://www.entityframework.info/Home/FullTextSearch:

public class FtsInterceptor : IDbCommandInterceptor
{
    private const string FullTextPrefix = "-FTSPREFIX-";

    public static string Fts(string search)
    {
        return string.Format("({0}{1})", FullTextPrefix, search);
    }

    public void NonQueryExecuting(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
    }

    public void NonQueryExecuted(DbCommand command, DbCommandInterceptionContext<int> interceptionContext)
    {
    }

    public void ReaderExecuting(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
    {
        RewriteFullTextQuery(command);
    }

    public void ReaderExecuted(DbCommand command, DbCommandInterceptionContext<DbDataReader> interceptionContext)
    {
    }

    public void ScalarExecuting(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
        RewriteFullTextQuery(command);
    }

    public void ScalarExecuted(DbCommand command, DbCommandInterceptionContext<object> interceptionContext)
    {
    }

    public static void RewriteFullTextQuery(DbCommand cmd)
    {
        string text = cmd.CommandText;
        for (int i = 0; i < cmd.Parameters.Count; i++)
        {
            DbParameter parameter = cmd.Parameters[i];
            if (parameter.DbType.In(DbType.String, DbType.AnsiString, DbType.StringFixedLength, DbType.AnsiStringFixedLength))
            {
                if (parameter.Value == DBNull.Value)
                    continue;
                var value = (string)parameter.Value;
                if (value.IndexOf(FullTextPrefix) >= 0)
                {
                    parameter.Size = 4096;
                    parameter.DbType = DbType.AnsiStringFixedLength;
                    value = value.Replace(FullTextPrefix, ""); // remove prefix we added n linq query
                    value = value.Substring(1, value.Length - 2);
                    // remove %% escaping by linq translator from string.Contains to sql LIKE
                    parameter.Value = value;
                    cmd.CommandText = Regex.Replace(text,
                        string.Format(
                            @"\[(\w*)\].\[(\w*)\]\s*LIKE\s*@{0}\s?(?:ESCAPE N?'~')",
                            parameter.ParameterName),
                        string.Format(@"contains([$1].[$2], @{0})",
                                    parameter.ParameterName));
                    if (text == cmd.CommandText)
                        throw new Exception("FTS was not replaced on: " + text);
                    text = cmd.CommandText;
                }
            }
        }
    }

}
static class LanguageExtensions
{
    public static bool In<T>(this T source, params T[] list)
    {
        return (list as IList<T>).Contains(source);
    }
}

For example, if you have class Note with FTS-indexed field NoteText:

public class Note
{
    public int NoteId { get; set; }
    public string NoteText { get; set; }
}

and EF map for it

public class NoteMap : EntityTypeConfiguration<Note>
{
    public NoteMap()
    {
        // Primary Key
        HasKey(t => t.NoteId);
    }
}

and context for it:

public class MyContext : DbContext
{
    static MyContext()
    {
        DbInterception.Add(new FtsInterceptor());
    }

    public MyContext(string nameOrConnectionString) : base(nameOrConnectionString)
    {
    }

    public DbSet<Note> Notes { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Configurations.Add(new NoteMap());
    }
}

you can have quite simple syntax to FTS query:

class Program
{
    static void Main(string[] args)
    {
        var s = FtsInterceptor.Fts("john");

        using (var db = new MyContext("CONNSTRING"))
        {
            var q = db.Notes.Where(n => n.NoteText.Contains(s));
            var result = q.Take(10).ToList();
        }
    }
}

That will generate SQL like

exec sp_executesql N'SELECT TOP (10) 
[Extent1].[NoteId] AS [NoteId], 
[Extent1].[NoteText] AS [NoteText]
FROM [NS].[NOTES] AS [Extent1]
WHERE contains([Extent1].[NoteText], @p__linq__0)',N'@p__linq__0 char(4096)',@p__linq__0='(john)   

Please notice that you should use local variable and cannot move FTS wrapper inside expression like

var q = db.Notes.Where(n => n.NoteText.Contains(FtsInterceptor.Fts("john")));

Upvotes: 55

Robert Ginsburg
Robert Ginsburg

Reputation: 149

I recently had a similar requirement and ended up writing an IQueryable extension specifically for Microsoft full text index access, its available here IQueryableFreeTextExtensions

Upvotes: 1

Matt
Matt

Reputation: 931

I have found that the easiest way to implement this is to setup and configure full-text-search in SQL Server and then use a stored procedure. Pass your arguments to SQL, allow the DB to do its job and return either a complex object or map the results to an entity. You don't necessarily have to have dynamic SQL, but it may be optimal. For example, if you need paging, you could pass in PageNumber and PageSize on every request without the need for dynamic SQL. However, if the number of arguments fluctuates per query, it will be the optimal solution.

Upvotes: 19

Related Questions