got search working with lucene.net

This commit is contained in:
tmont 2011-02-17 05:30:31 +00:00
parent 973b8196d9
commit f7de96eb63
16 changed files with 270 additions and 15 deletions

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace VideoGameQuotes.Api.Search {
/// <summary>
/// Exposes an interface to search for quotes
/// </summary>
public interface IQuoteSearcher {
/// <summary>
/// Searches for quote based on the given search query
/// </summary>
IEnumerable<SearchResult> Search(string query);
/// <summary>
/// (Re)builds the search index
/// </summary>
/// <param name="replaceOldIndex">Set to false to not overwrite the old index with a brand new one.</param>
void BuildIndex(bool replaceOldIndex = true);
}
}

View File

@ -0,0 +1,15 @@
namespace VideoGameQuotes.Api.Search {
public interface ISearchIndexLocator {
string IndexDirectory { get; }
}
public class SearchIndexLocator : ISearchIndexLocator {
private readonly string indexDirectory;
public SearchIndexLocator(string indexDirectory) {
this.indexDirectory = indexDirectory;
}
public string IndexDirectory { get { return indexDirectory; } }
}
}

View File

@ -0,0 +1,13 @@
using System.Collections.Generic;
using Lucene.Net.Search;
namespace VideoGameQuotes.Api.Search.Lucene {
public static class LuceneExtensions {
public static IEnumerable<Hit> ToEnumerable(this Hits hits) {
var iterator = hits.Iterator();
while (iterator.MoveNext()) {
yield return (Hit)iterator.Current;
}
}
}
}

View File

@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Linq;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using Portoa.Persistence;
namespace VideoGameQuotes.Api.Search.Lucene {
/// <summary>
/// <see cref="IQuoteSearcher"/> implementation based on <c>Lucene.NET</c>
/// </summary>
public class LuceneQuoteSearcher : IQuoteSearcher {
private readonly ISearchIndexLocator indexLocator;
private readonly IRepository<Quote> quoteRepository;
private readonly Analyzer analyzer = new StandardAnalyzer();
private readonly QueryParser queryParser;
public LuceneQuoteSearcher(ISearchIndexLocator indexLocator, IRepository<Quote> quoteRepository) {
this.indexLocator = indexLocator;
this.quoteRepository = quoteRepository;
queryParser = new QueryParser("text", analyzer);
}
[UnitOfWork]
public IEnumerable<SearchResult> Search(string searchString) {
var query = queryParser.Parse(QueryParser.Escape(searchString));
var searcher = new IndexSearcher(indexLocator.IndexDirectory);
return searcher
.Search(query)
.ToEnumerable()
.Select(hit => new SearchResult {
Score = hit.GetScore(),
Quote = quoteRepository.FindById(int.Parse(hit.GetDocument().GetField("id").StringValue()))
});
}
[UnitOfWork]
public void BuildIndex(bool replaceOldIndex = true) {
var indexWriter = new IndexWriter(indexLocator.IndexDirectory, analyzer, replaceOldIndex);
foreach (var quote in quoteRepository.Records) {
indexWriter.AddDocument(CreateDocument(quote));
}
indexWriter.Optimize();
indexWriter.Close();
}
private static Document CreateDocument(Quote quote) {
var document = new Document();
document.Add(new Field("id", quote.Id.ToString(), Field.Store.YES, Field.Index.NO));
document.Add(new Field("text", quote.Text, Field.Store.YES, Field.Index.TOKENIZED));
return document;
}
}
}

View File

@ -0,0 +1,17 @@
namespace VideoGameQuotes.Api.Search {
/// <summary>
/// Represents a search result
/// </summary>
public class SearchResult {
/// <summary>
/// A value (between 0 and 1, the higher the better) representing how good
/// the match is between the search query and the value
/// </summary>
public double Score { get; set; }
/// <summary>
/// The matched quote
/// </summary>
public Quote Quote { get; set; }
}
}

View File

@ -37,6 +37,9 @@
<Reference Include="log4net"> <Reference Include="log4net">
<HintPath>..\..\Lib\log4net.dll</HintPath> <HintPath>..\..\Lib\log4net.dll</HintPath>
</Reference> </Reference>
<Reference Include="Lucene.Net">
<HintPath>..\..\Lib\Lucene.Net.dll</HintPath>
</Reference>
<Reference Include="MySql.Data"> <Reference Include="MySql.Data">
<HintPath>..\..\Lib\MySql.Data.dll</HintPath> <HintPath>..\..\Lib\MySql.Data.dll</HintPath>
</Reference> </Reference>
@ -58,11 +61,7 @@
<Reference Include="System" /> <Reference Include="System" />
<Reference Include="System.ComponentModel.DataAnnotations" /> <Reference Include="System.ComponentModel.DataAnnotations" />
<Reference Include="System.Core" /> <Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" /> <Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="ApiException.cs" /> <Compile Include="ApiException.cs" />
@ -71,6 +70,10 @@
<Compile Include="CriterionHandler.cs" /> <Compile Include="CriterionHandler.cs" />
<Compile Include="Game.cs" /> <Compile Include="Game.cs" />
<Compile Include="ICurrentUserProvider.cs" /> <Compile Include="ICurrentUserProvider.cs" />
<Compile Include="Search\IQuoteSearcher.cs" />
<Compile Include="Search\ISearchIndexLocator.cs" />
<Compile Include="Search\Lucene\LuceneQuoteSearcher.cs" />
<Compile Include="Search\Lucene\LuceneExtensions.cs" />
<Compile Include="Persistence\IUserRepository.cs" /> <Compile Include="Persistence\IUserRepository.cs" />
<Compile Include="Persistence\UserService.cs" /> <Compile Include="Persistence\UserService.cs" />
<Compile Include="QuoteFlag.cs" /> <Compile Include="QuoteFlag.cs" />
@ -80,6 +83,7 @@
<Compile Include="QuoteFlagType.cs" /> <Compile Include="QuoteFlagType.cs" />
<Compile Include="Region.cs" /> <Compile Include="Region.cs" />
<Compile Include="GamingSystem.cs" /> <Compile Include="GamingSystem.cs" />
<Compile Include="Search\SearchResult.cs" />
<Compile Include="User.cs" /> <Compile Include="User.cs" />
<Compile Include="UserGroup.cs" /> <Compile Include="UserGroup.cs" />
<Compile Include="Vote.cs" /> <Compile Include="Vote.cs" />

View File

@ -8,6 +8,7 @@ using Portoa.Persistence;
using Portoa.Web.Controllers; using Portoa.Web.Controllers;
using Portoa.Web.Results; using Portoa.Web.Results;
using VideoGameQuotes.Api; using VideoGameQuotes.Api;
using VideoGameQuotes.Api.Search;
using VideoGameQuotes.Web.Models; using VideoGameQuotes.Web.Models;
using VideoGameQuotes.Web.Security; using VideoGameQuotes.Web.Security;
using VideoGameQuotes.Web.Services; using VideoGameQuotes.Web.Services;
@ -16,10 +17,12 @@ namespace VideoGameQuotes.Web.Controllers {
public class QuoteController : Controller { public class QuoteController : Controller {
private readonly IQuoteService quoteService; private readonly IQuoteService quoteService;
private readonly ICurrentUserProvider currentUserProvider; private readonly ICurrentUserProvider currentUserProvider;
private readonly IQuoteSearcher quoteSearcher;
public QuoteController(IQuoteService quoteService, ICurrentUserProvider currentUserProvider) { public QuoteController(IQuoteService quoteService, ICurrentUserProvider currentUserProvider, IQuoteSearcher quoteSearcher) {
this.quoteService = quoteService; this.quoteService = quoteService;
this.currentUserProvider = currentUserProvider; this.currentUserProvider = currentUserProvider;
this.quoteSearcher = quoteSearcher;
} }
public ActionResult Browse(BrowseModel model) { public ActionResult Browse(BrowseModel model) {
@ -27,7 +30,10 @@ namespace VideoGameQuotes.Web.Controllers {
return View("DefaultBrowse"); return View("DefaultBrowse");
} }
return View("QualifiedBrowse", new QualifiedBrowseModel(model) { Quotes = quoteService.GetBrowsableQuotes(model) }); return View("QualifiedBrowse", new QualifiedBrowseModel(model) {
Quotes = quoteService.GetBrowsableQuotes(model),
User = currentUserProvider.CurrentUser
});
} }
[HttpPost, IsValidUser] [HttpPost, IsValidUser]
@ -237,5 +243,15 @@ namespace VideoGameQuotes.Web.Controllers {
return Json(this.CreateJsonErrorResponse(e)); return Json(this.CreateJsonErrorResponse(e));
} }
} }
public ActionResult Search(string searchQuery) {
var model = new SearchModel {
User = currentUserProvider.CurrentUser,
Results = quoteSearcher.Search(searchQuery),
SearchQuery = searchQuery
};
return View(model);
}
} }
} }

View File

@ -1,12 +1,18 @@
using System.Web.Mvc; using System.Collections.Specialized;
using System.Configuration;
using System.Web.Mvc;
using System.Web.Routing; using System.Web.Routing;
using Microsoft.Practices.Unity; using Microsoft.Practices.Unity;
using Portoa.Logging;
using Portoa.Persistence;
using Portoa.Web; using Portoa.Web;
using Portoa.Web.Models; using Portoa.Web.Models;
using Portoa.Web.Unity; using Portoa.Web.Unity;
using UnityGenerics; using UnityGenerics;
using VideoGameQuotes.Api; using VideoGameQuotes.Api;
using VideoGameQuotes.Api.Persistence; using VideoGameQuotes.Api.Persistence;
using VideoGameQuotes.Api.Search;
using VideoGameQuotes.Api.Search.Lucene;
using VideoGameQuotes.Web.Controllers; using VideoGameQuotes.Web.Controllers;
using VideoGameQuotes.Web.Models; using VideoGameQuotes.Web.Models;
using VideoGameQuotes.Web.Security; using VideoGameQuotes.Web.Security;
@ -36,23 +42,33 @@ namespace VideoGameQuotes.Web {
.RegisterType<IUserService, UserService>() .RegisterType<IUserService, UserService>()
.RegisterType<IQuoteService, QuoteService>() .RegisterType<IQuoteService, QuoteService>()
.RegisterType<IApiService, ApiService>() .RegisterType<IApiService, ApiService>()
.RegisterType<IUserRepository, UserRepository>(); .RegisterType<IQuoteSearcher, LuceneQuoteSearcher>()
.RegisterType<ISearchIndexLocator, SearchIndexLocator>(
new ContainerControlledLifetimeManager(),
new InjectionFactory(container => new SearchIndexLocator(((NameValueCollection)ConfigurationManager.GetSection("vgquotes"))["luceneIndexDirectory"]))
).RegisterType<IUserRepository, UserRepository>();
}
protected override void AfterStartUp() {
var logger = Container.Resolve<ILogger>();
logger.Info("Building lucene index");
Container.Resolve<IQuoteSearcher>().BuildIndex();
logger.Info("Done building lucene index");
} }
protected override void RegisterRoutes(RouteCollection routes) { protected override void RegisterRoutes(RouteCollection routes) {
routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.IgnoreRoute("media/{*anything}"); routes.IgnoreRoute("media/{*anything}");
routes.MapRoute("api", "api/{action}/{id}/{*criteria}", new { controller = "Api" }, new { action = "game|system|category|publisher|quote", id = @"\d+|all" }); routes.MapRoute("api", "api/{action}/{id}/{*criteria}", new { controller = "Api" }, new { action = "game|system|category|publisher|quote", id = @"\d+|all" });
routes.MapRoute("home", "{action}", new { controller = "Home", action = "Index" }, new { action = "about|contact" }); routes.MapRoute("home", "{action}", new { controller = "Home", action = "Index" }, new { action = "about|contact" });
routes.MapRoute("best", "best/{start}-{end}/", new { controller = "Quote", action = "Best" }, new { start = @"\d+", end = @"\d+" }); routes.MapRoute("best", "best/{start}-{end}/", new { controller = "Quote", action = "Best" }, new { start = @"\d+", end = @"\d+" });
routes.MapRoute("browse", "browse/{*qualifiers}", new { controller = "Quote", action = "Browse" }); routes.MapRoute("browse", "browse/{*qualifiers}", new { controller = "Quote", action = "Browse" });
routes.MapRoute("quote", "{action}", new { controller = "Quote" }, new { action = "submit|search|recent|random|best|vote|report" }); routes.MapRoute("search", "search/{*searchQuery}", new { controller = "Quote", action = "Search" });
routes.MapRoute("quote", "{action}", new { controller = "Quote" }, new { action = "submit|recent|random|best|vote|report" });
routes.MapRoute("individual-quote", "quote/{id}/{*text}", new { controller = "Quote", action = "Quote" }, new { id = @"\d+" }); routes.MapRoute("individual-quote", "quote/{id}/{*text}", new { controller = "Quote", action = "Quote" }, new { id = @"\d+" });
routes.MapRoute("create-category", "category/create", new { controller = "Quote", action = "CreateCategory" }); routes.MapRoute("create-category", "category/create", new { controller = "Quote", action = "CreateCategory" });
routes.MapRoute("default", "{controller}", new { controller = "home", action = "index" });
} }
} }
} }

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
using VideoGameQuotes.Api;
using VideoGameQuotes.Api.Search;
namespace VideoGameQuotes.Web.Models {
public class SearchModel {
public User User { get; set; }
public IEnumerable<SearchResult> Results { get; set; }
public string SearchQuery { get; set; }
}
}

View File

@ -94,6 +94,7 @@
<Compile Include="Models\QuoteCollectionModel.cs" /> <Compile Include="Models\QuoteCollectionModel.cs" />
<Compile Include="Models\QuoteModel.cs" /> <Compile Include="Models\QuoteModel.cs" />
<Compile Include="Models\ReportModel.cs" /> <Compile Include="Models\ReportModel.cs" />
<Compile Include="Models\SearchModel.cs" />
<Compile Include="Models\VoteModel.cs" /> <Compile Include="Models\VoteModel.cs" />
<Compile Include="Validation\NonEmptyText.cs" /> <Compile Include="Validation\NonEmptyText.cs" />
<Compile Include="Security\IsValidUserAttribute.cs" /> <Compile Include="Security\IsValidUserAttribute.cs" />
@ -134,6 +135,7 @@
<Content Include="Views\Quote\Quote.aspx" /> <Content Include="Views\Quote\Quote.aspx" />
<Content Include="Views\Quote\QuoteNotFound.aspx" /> <Content Include="Views\Quote\QuoteNotFound.aspx" />
<Content Include="Views\Quote\Recent.aspx" /> <Content Include="Views\Quote\Recent.aspx" />
<Content Include="Views\Quote\Search.aspx" />
<Content Include="Views\Quote\Submit.aspx" /> <Content Include="Views\Quote\Submit.aspx" />
<Content Include="Views\Shared\ExceptionView.ascx" /> <Content Include="Views\Shared\ExceptionView.ascx" />
<Content Include="Views\Shared\Forbidden.aspx" /> <Content Include="Views\Shared\Forbidden.aspx" />

View File

@ -0,0 +1,14 @@
<%@ Page Title="" Language="C#" Inherits="System.Web.Mvc.ViewPage<VideoGameQuotes.Web.Models.SearchModel>" MasterPageFile="~/Views/Shared/Site.Master" %>
<%@ Import Namespace="VideoGameQuotes.Web.Models" %>
<asp:Content runat="server" ID="Title" ContentPlaceHolderID="TitleContent">Search<%= !string.IsNullOrEmpty(Model.SearchQuery) ? " - " + Model.SearchQuery : "" %></asp:Content>
<asp:Content runat="server" ID="Main" ContentPlaceHolderID="MainContent">
<p>
Search results for: <span class="Search-query"><%: Model.SearchQuery %></span>
</p>
<%
foreach (var result in Model.Results) {
Html.RenderPartial("SingleQuote", new QuoteModel {Quote = result.Quote, User = Model.User });
}
%>
</asp:Content>

View File

@ -29,9 +29,9 @@
<li><%= Html.ActionLink("Submit", "submit", "Quote", null, new { title = "Submit a new quote" }) %></li> <li><%= Html.ActionLink("Submit", "submit", "Quote", null, new { title = "Submit a new quote" }) %></li>
<li><%= Html.ActionLink("About", "about", "Home", null, new { title = "About the site" })%></li> <li><%= Html.ActionLink("About", "about", "Home", null, new { title = "About the site" })%></li>
<li class="searchbox"> <li class="searchbox">
<% using (Html.BeginForm("search", "quote", FormMethod.Get)) { %> <% using (Html.BeginForm("search", "quote", FormMethod.Get, new { id = "search-form" })) { %>
<div> <div>
<%= Html.TextBox("term") %> <%= Html.TextBox("searchQuery", null, new { id = "search-query" })%>
<input type="image" src="/media/images/search.png" alt="search" title="search quotes, games, systems" /> <input type="image" src="/media/images/search.png" alt="search" title="search quotes, games, systems" />
</div> </div>
<% } %> <% } %>

View File

@ -4,8 +4,13 @@
<configSections> <configSections>
<!--<section name="unity" type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection"/>--> <!--<section name="unity" type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection"/>-->
<section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/> <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>
<section name="vgquotes" type="System.Configuration.NameValueSectionHandler"/>
</configSections> </configSections>
<vgquotes>
<add key="luceneIndexDirectory" value="c:\users\tmont\code\VideoGameQuotes\lucene_index"/>
</vgquotes>
<log4net> <log4net>
<appender name="DebugAppender" type="log4net.Appender.DebugAppender"> <appender name="DebugAppender" type="log4net.Appender.DebugAppender">
<layout type="log4net.Layout.PatternLayout"> <layout type="log4net.Layout.PatternLayout">

View File

@ -7,11 +7,19 @@
} }
$(document).ready(function() { $(document).ready(function() {
$("#search-form").submit(function() {
var searchQuery = $.trim($("#search-query").val());
if (searchQuery.length > 0) {
window.location = "/search/" + searchQuery;
}
return false;
});
var getQuoteId = function($container) { var getQuoteId = function($container) {
return $container.find("input.quote-id").val(); return $container.find("input.quote-id").val();
}; };
var voting = false; var voting = false;
$(".vote-for, .vote-against").live("click", function() { $(".vote-for, .vote-against").live("click", function() {
if (voting) { if (voting) {

View File

@ -0,0 +1,53 @@
using System;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using NUnit.Framework;
using VideoGameQuotes.Api.Search.Lucene;
namespace VideoGameQuotes.Api.Tests {
[TestFixture, Ignore]
public class LuceneTests {
[Test]
public void Should_use_lucene() {
var analyzer = new StandardAnalyzer(new [] { "the", "of", "a", "an" });
const string indexDirectory = @"c:\users\tmont\code\lucene_index_test";
var indexWriter = new Lucene.Net.Index.IndexWriter(indexDirectory, analyzer, true);
indexWriter.AddDocument(CreateDocument(4, "cat"));
indexWriter.AddDocument(CreateDocument(5, "catty"));
indexWriter.AddDocument(CreateDocument(6, "Catherine"));
indexWriter.AddDocument(CreateDocument(7, "vacate"));
indexWriter.AddDocument(CreateDocument(8, "cat cat cat"));
indexWriter.AddDocument(CreateDocument(9, "i'm trying to herd a cat, a cat, I say!"));
indexWriter.AddDocument(CreateDocument(10, "the cat"));
indexWriter.Optimize();
indexWriter.Close();
var parser = new QueryParser("text", analyzer);
var query = parser.Parse("cat");
var searcher = new IndexSearcher(indexDirectory);
var hits = searcher.Search(query).ToEnumerable();
foreach (var hit in hits) {
Console.WriteLine("id: {0}, document: {1}, score: {2}", hit.GetId(), hit.GetDocument(), hit.GetScore());
}
}
private static Document CreateDocument(int id, string text) {
var document = new Document();
document.Add(new Field("id", id.ToString(), Field.Store.YES, Field.Index.UN_TOKENIZED));
document.Add(new Field("text", text, Field.Store.YES, Field.Index.TOKENIZED));
return document;
}
}
}

View File

@ -34,6 +34,9 @@
<Reference Include="log4net"> <Reference Include="log4net">
<HintPath>..\..\Lib\log4net.dll</HintPath> <HintPath>..\..\Lib\log4net.dll</HintPath>
</Reference> </Reference>
<Reference Include="Lucene.Net">
<HintPath>..\..\Lib\Lucene.Net.dll</HintPath>
</Reference>
<Reference Include="Moq"> <Reference Include="Moq">
<HintPath>..\..\Lib\Moq.dll</HintPath> <HintPath>..\..\Lib\Moq.dll</HintPath>
</Reference> </Reference>
@ -63,6 +66,7 @@
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="LuceneTests.cs" />
<Compile Include="QuoteTests.cs" /> <Compile Include="QuoteTests.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="NHibernate\SchemaExporter.cs" /> <Compile Include="NHibernate\SchemaExporter.cs" />