Matthew
Matthew

Reputation: 9949

Code-First Entity Framework w/ Stored Procedure returning results from complex Full-text Searches

I am looking for design advice for the following scenario:

I have a code-first EF5 MVC application. I am building a full-text search function which will incorporate multiple weighted columns from many tables. As I cannot create view with an index from these tables (some of them contain text / binary columns), I have created a stored procedure which will output the ID of my object (eg. PersonID) and the rank associated with that object based on the search terms.

My current approach is to create a helper class for executing full text searches which call the stored procedure(s) and load all the objects from the context based on the returned IDs.

My questions are:

  1. Does my approach seem sensible / follow reasonable best practice?
  2. Has anyone else done something similar with any lessons learned?
  3. Is there a way to do this more efficiently (i.e. have the results of the stored procedure return/map to the entities directly without an additional look-up required?)

UPDATE

Moved my detailed implementation from an edit of the question into its own answer to be more in line with what is recommended frequently @ meta.stackexchange.com

Upvotes: 16

Views: 13744

Answers (2)

Drauka
Drauka

Reputation: 1217

  1. Seeing as you can't use SQL methods like containstable with entityframework code first which the rest of your application could be using you could be 'forced' to do something with a storedprocedure like your describe. Whether it's best practice I don't know. However it it gets the job done I don't see why it wouldn't be sensible.
  2. Yes - I have and still am working on a project build around EF codefirst where I had to do a fairly complex search that included several search parameters marked as 'must have' and several values marked as 'nice to have' and in from that return a weighted result.
  3. Depending on the complexity of the result set I don't think you need to do a second roundtrip to the database and I will show you a way I have been doing it below.

Bear in mind that below is simply an example:

    public List<Person> GetPeople(params string[] p)
    {
        var people = new List<Person>();

        using (var db = new DataContext())
        {
            var context = ((IObjectContextAdapter)db).ObjectContext;

            db.Database.Connection.Open();

            var command = db.Database.Connection.CreateCommand();
            command.CommandText = "SomeStoredProcedureReturningWeightedResultSetOfPeople";
            command.CommandType = System.Data.CommandType.StoredProcedure;

            //Add parameters to command object

            people = context.Translate<Person>(command.ExecuteReader()).ToList();
        }

        return people;
    }

Even though the storedprocedure will have a column for the weight value it won't get mapped when you translate it. You could potentially derive a class from Person that includes the weight value if you needed it.

Upvotes: 13

Matthew
Matthew

Reputation: 9949

Posting this as an answer rather than an edit to my question:

Taking some of the insight provided by @Drauka's (and google) here is what I did for my initial iteration.

  1. Created the stored procedure to do the full text searching. It was really too complex to be done in EF even if supported (as one example some of my entities are related via business logic and I wanted to group them returning as a single result). The stored procedure maps to a DTO with the entity id's and a Rank.
  2. I modified this blogger's snippet / code to make the call to the stored procedure, and populate my DTO: http://www.lucbos.net/2012/03/calling-stored-procedure-with-entity.html
  3. I populate my results object with totals and paging information from the results of the stored procedure and then just load the entities for the current page of results:

    int[] projectIDs = new int[Settings.Default.ResultsPerPage];
    foreach (ProjectFTS_DTO dto in 
              RankedSearchResults
              .Skip(Settings.Default.ResultsPerPage * (pageNum - 1))
              .Take(Settings.Default.ResultsPerPage)) {
                 projectIDs[index] = dto.ProjectID;
                 index++;
            }
    
    IEnumerable<Project> projects = _repository.Projects
                .Where(o=>projectIDs.Contains(o.ProjectID));
    

Full Implementation:

As this question receives a lot of views I thought it may be worth while to post more details of my final solution for others help or possible improvement.

The complete solution looks like:

DatabaseExtensions class:

public static class DatabaseExtensions {
    public static IEnumerable<TResult> ExecuteStoredProcedure<TResult>(
             this Database database, 
             IStoredProcedure<TResult> procedure, 
             string spName) {
        var parameters = CreateSqlParametersFromProperties(procedure);
        var format = CreateSPCommand<TResult>(parameters, spName);
        return database.SqlQuery<TResult>(format, parameters.Cast<object>().ToArray());
    }

    private static List<SqlParameter> CreateSqlParametersFromProperties<TResult>
             (IStoredProcedure<TResult> procedure) {
        var procedureType = procedure.GetType();
        var propertiesOfProcedure = procedureType.GetProperties(BindingFlags.Public | BindingFlags.Instance);

        var parameters =
            propertiesOfProcedure.Select(propertyInfo => new SqlParameter(
                    string.Format("@{0}", 
                    (object) propertyInfo.Name), 
                    propertyInfo.GetValue(procedure, new object[] {})))
                .ToList();
        return parameters;
    }

    private static string CreateSPCommand<TResult>(List<SqlParameter> parameters, string spName)
    {
        var name = typeof(TResult).Name;
        string queryString = string.Format("{0}", spName);
        parameters.ForEach(x => queryString = string.Format("{0} {1},", queryString, x.ParameterName));

        return queryString.TrimEnd(',');
    }

    public interface IStoredProcedure<TResult> {
    }
}

Class to hold stored proc inputs:

class AdvancedFTS : 
         DatabaseExtensions.IStoredProcedure<AdvancedFTSDTO> {
    public string SearchText { get; set; }
    public int MinRank { get; set; }
    public bool IncludeTitle { get; set; }
    public bool IncludeDescription { get; set; }
    public int StartYear { get; set; }
    public int EndYear { get; set; }
    public string FilterTags { get; set; }
}

Results object:

public class ResultsFTSDTO {
    public int ID { get; set; }
    public decimal weightRank { get; set; }
}

Finally calling the stored procedure:

public List<ResultsFTSDTO> getAdvancedFTSResults(
            string searchText, int minRank,
            bool IncludeTitle,
            bool IncludeDescription,
            int StartYear,
            int EndYear,
            string FilterTags) {

        AdvancedFTS sp = new AdvancedFTS() {
            SearchText = searchText,
            MinRank = minRank,
            IncludeTitle=IncludeTitle,
            IncludeDescription=IncludeDescription,
            StartYear=StartYear,
            EndYear = EndYear,
            FilterTags=FilterTags
        };
        IEnumerable<ResultsFTSDTO> resultSet = _context.Database.ExecuteStoredProcedure(sp, "ResultsAdvancedFTS");
        return resultSet.ToList();

    }

Upvotes: 11

Related Questions