Skip to content

Commit

Permalink
Fix for: error on bad search term #117 (#118)
Browse files Browse the repository at this point in the history
  • Loading branch information
petervandenhout committed Apr 20, 2021
1 parent 4694ec4 commit 81ff960
Show file tree
Hide file tree
Showing 12 changed files with 243 additions and 58 deletions.
1 change: 0 additions & 1 deletion src/Opw.PineBlog.Abstractions/ExceptionExtensions.cs
Expand Up @@ -4,7 +4,6 @@

namespace Opw.PineBlog
{
//TODO: move to Opw.Common
public static class ExceptionExtensions
{
public static string GetAggregatedExceptionMessage(this ValidationErrorException<ValidationFailure> ex)
Expand Down
10 changes: 10 additions & 0 deletions src/Opw.PineBlog.Core/Posts/Search/IPostRanker.cs
@@ -0,0 +1,10 @@
using Opw.PineBlog.Entities;
using System.Collections.Generic;

namespace Opw.PineBlog.Posts.Search
{
public interface IPostRanker
{
IEnumerable<Post> Rank(IEnumerable<Post> posts, string query);
}
}
56 changes: 56 additions & 0 deletions src/Opw.PineBlog.Core/Posts/Search/PostRanker.cs
@@ -0,0 +1,56 @@
using Opw.PineBlog.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Opw.PineBlog.Posts.Search
{
// TODO: add more test for ranking (hits count)
public class PostRanker : IPostRanker
{
public IEnumerable<Post> Rank(IEnumerable<Post> posts, string query)
{
if (posts == null || !posts.Any())
{
return new List<Post>();
}

var terms = query.ParseTerms();
var rankedPosts = new List<Tuple<Post, int>>();

foreach (var post in posts)
{
var rank = 0;
foreach (var term in terms)
{
int hits;
if (!string.IsNullOrWhiteSpace(post.Title) && post.Title.ToLower().Contains(term))
{
hits = Regex.Matches(post.Title.ToLower(), term).Count;
rank += hits * 10;
}
if (!string.IsNullOrWhiteSpace(post.Categories) && post.Categories.ToLower().Contains(term))
{
hits = Regex.Matches(post.Categories.ToLower(), term).Count;
rank += hits * 10;
}
if (!string.IsNullOrWhiteSpace(post.Description) && post.Description.ToLower().Contains(term))
{
hits = Regex.Matches(post.Description.ToLower(), term).Count;
rank += hits * 3;
}
if (!string.IsNullOrWhiteSpace(post.Content) && post.Content.ToLower().Contains(term))
{
hits = Regex.Matches(post.Content.ToLower(), term).Count;
rank += hits * 1;
}
}

rankedPosts.Add(new Tuple<Post, int>(post, rank));
}

return rankedPosts.OrderByDescending(t => t.Item2).Select(t => t.Item1).ToList();
}
}
}
Expand Up @@ -7,13 +7,12 @@
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

// TODO: improve test coverage
namespace Opw.PineBlog.Posts
namespace Opw.PineBlog.Posts.Search
{
/// <summary>
/// Query that searches posts.
Expand Down Expand Up @@ -42,20 +41,28 @@ public class Handler : IRequestHandler<SearchPostsQuery, Result<PostListModel>>
{
private readonly IOptionsSnapshot<PineBlogOptions> _blogOptions;
private readonly IBlogUnitOfWork _uow;
private readonly IPostRanker _postRanker;
private readonly PostUrlHelper _postUrlHelper;
private readonly FileUrlHelper _fileUrlHelper;

/// <summary>
/// Implementation of SearchPostsQuery.Handler.
/// </summary>
/// <param name="uow">The blog unit of work.</param>
/// <param name="postRanker">Post ranker.</param>
/// <param name="blogOptions">The blog options.</param>
/// <param name="postUrlHelper">Post URL helper.</param>
/// <param name="fileUrlHelper">File URL helper.</param>
public Handler(IBlogUnitOfWork uow, IOptionsSnapshot<PineBlogOptions> blogOptions, PostUrlHelper postUrlHelper, FileUrlHelper fileUrlHelper)
public Handler(
IBlogUnitOfWork uow,
IPostRanker postRanker,
IOptionsSnapshot<PineBlogOptions> blogOptions,
PostUrlHelper postUrlHelper,
FileUrlHelper fileUrlHelper)
{
_blogOptions = blogOptions;
_uow = uow;
_postRanker = postRanker;
_postUrlHelper = postUrlHelper;
_fileUrlHelper = fileUrlHelper;
}
Expand All @@ -82,7 +89,7 @@ public async Task<Result<PostListModel>> Handle(SearchPostsQuery request, Cancel
pagingUrlPartFormat += "&" + string.Format(_blogOptions.Value.SearchQueryUrlPartFormat, HttpUtility.UrlEncode(request.SearchQuery));

posts = await _uow.Posts.GetAsync(predicates, 0, int.MaxValue, cancellationToken);
posts = RankPosts(posts, request.SearchQuery);
posts = _postRanker.Rank(posts, request.SearchQuery);
posts = await GetPagedListAsync(posts, predicates, pager, pagingUrlPartFormat, cancellationToken);
}
else
Expand Down Expand Up @@ -111,19 +118,12 @@ public async Task<Result<PostListModel>> Handle(SearchPostsQuery request, Cancel
return Result<PostListModel>.Success(model);
}

private IEnumerable<string> ParseTerms(string query)
{
// convert multiple spaces into one space
query = Regex.Replace(query, @"\s+", " ").Trim();
return query.ToLower().Split(' ').ToList();
}

private Expression<Func<Post, bool>> BuildSearchExpression(string query)
{
var parameterExp = Expression.Parameter(typeof(Post), "p");
Expression exp = null;

foreach (var term in ParseTerms(query))
foreach (var term in query.ParseTerms())
{
exp = ConcatOr(exp, GetContainsExpression(nameof(Post.Title), term.Trim(), parameterExp));
exp = ConcatOr(exp, GetContainsExpression(nameof(Post.Description), term.Trim(), parameterExp));
Expand Down Expand Up @@ -152,46 +152,6 @@ private Expression GetContainsExpression(string propertyName, string term, Param
return Expression.Call(propertyExp, method, someValue);
}

// TODO: test ranking
private IEnumerable<Post> RankPosts(IEnumerable<Post> posts, string query)
{
var terms = ParseTerms(query);
var rankedPosts = new List<Tuple<Post, int>>();

foreach (var post in posts)
{
var rank = 0;
foreach (var term in terms)
{
int hits;
if (post.Title.ToLower().Contains(term))
{
hits = Regex.Matches(post.Title.ToLower(), term).Count;
rank += hits * 10;
}
if (post.Categories.ToLower().Contains(term))
{
hits = Regex.Matches(post.Categories.ToLower(), term).Count;
rank += hits * 10;
}
if (post.Description.ToLower().Contains(term))
{
hits = Regex.Matches(post.Description.ToLower(), term).Count;
rank += hits * 3;
}
if (post.Content.ToLower().Contains(term))
{
hits = Regex.Matches(post.Content.ToLower(), term).Count;
rank += hits * 1;
}
}

rankedPosts.Add(new Tuple<Post, int>(post, rank));
}

return rankedPosts.OrderByDescending(t => t.Item2).Select(t => t.Item1).ToList();
}

private async Task<IEnumerable<Post>> GetPagedListAsync(IEnumerable<Expression<Func<Post, bool>>> predicates, Pager pager, string pagingUrlPartFormat, CancellationToken cancellationToken)
{
var skip = (pager.CurrentPage - 1) * pager.ItemsPerPage;
Expand Down
22 changes: 22 additions & 0 deletions src/Opw.PineBlog.Core/Posts/Search/SearchQueryExtensions.cs
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Opw.PineBlog.Posts.Search
{
public static class SearchQueryExtensions
{
public static IEnumerable<string> ParseTerms(this string query)
{
if (string.IsNullOrEmpty(query))
{
return Array.Empty<string>();
}

// convert multiple spaces into one space
query = Regex.Replace(query, @"\s+", " ").Trim();
return query.ToLower().Split(' ').ToList();
}
}
}
3 changes: 3 additions & 0 deletions src/Opw.PineBlog.Core/ServiceCollectionExtensions.cs
Expand Up @@ -12,6 +12,7 @@
using Opw.PineBlog.Files.Azure;
using Opw.PineBlog.Feeds;
using Opw.PineBlog.Blogs;
using Opw.PineBlog.Posts.Search;

namespace Opw.PineBlog
{
Expand Down Expand Up @@ -52,6 +53,8 @@ public static IServiceCollection AddPineBlogCore(this IServiceCollection service

services.AddTransient<IValidator<UpdateBlogSettingsCommand>, UpdateBlogSettingsCommandValidator>();

services.AddTransient<IPostRanker, PostRanker>();

services.AddTransient<IUploadFileCommandFactory, UploadFileCommandFactory>();
services.AddTransient<IDeleteFileCommandFactory, DeleteFileCommandFactory>();
services.AddTransient<IGetPagedFileListQueryFactory, GetPagedFileListQueryFactory>();
Expand Down
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;
using Opw.PineBlog.Models;
using Opw.PineBlog.Posts;
using Opw.PineBlog.Posts.Search;
using System.Threading;
using System.Threading.Tasks;

Expand Down
69 changes: 69 additions & 0 deletions tests/Opw.PineBlog.Core.Tests/Posts/Search/PostRankerTests.cs
@@ -0,0 +1,69 @@
using FluentAssertions;
using Opw.PineBlog.Entities;
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace Opw.PineBlog.Posts.Search
{
public class PostRankerTests
{
[Fact]
public void Rank_Should_0Posts_WhenPostsNull()
{
var query = "c# dotnet pineblog";

var results = new PostRanker().Rank(null, query);

results.Should().HaveCount(0);
}

[Fact]
public void Rank_Should_2Posts_WhenQueryNull()
{
var posts = new List<Post>
{
new Post { Slug = "1", Title = "pineblog", Categories = "pineblog", Description = "pineblog", Content = "pinelog" },
new Post { Slug = "2", Title = "pineblog", Categories = "pineblog", Description = "pineblog", Content = "pinelog" },
};

var results = new PostRanker().Rank(posts, null);

results.Should().HaveCount(2);
}

[Fact]
public void Rank_Should_2Posts_WhenPostProperiesNull()
{
var query = "c# dotnet pineblog";
var posts = new List<Post>
{
new Post { Slug = "1", Title = "pineblog", Categories = "pineblog", Description = "pineblog", Content = "pinelog" },
new Post { Slug = "2", Title = null, Categories = null, Description = null, Content = null },
};

var results = new PostRanker().Rank(posts, query);

results.Should().HaveCount(2);
}

[Fact]
public void Rank_Should_PostsRankedCorrectly_ForOneMatchingTerm()
{
var query = "c# dotnet pineblog";
var posts = new List<Post>
{
new Post { Slug = "5", Title = "xxx", Categories = "xxx", Description = "xxx", Content = "xxx" },
new Post { Slug = "4", Title = "pineblog", Categories = "xxx", Description = "xxx", Content = "xxx" },
new Post { Slug = "3", Title = "pineblog", Categories = "pineblog", Description = "xxx", Content = "xxx" },
new Post { Slug = "2", Title = "pineblog", Categories = "pineblog", Description = "pineblog", Content = "xxx" },
new Post { Slug = "1", Title = "pineblog", Categories = "pineblog", Description = "pineblog", Content = "pinelog" },
};

var results = new PostRanker().Rank(posts, query);

results.Should().HaveCount(5);
results.Select(p => p.Slug).Should().BeEquivalentTo(new string[] { "1", "2", "3", "4", "5" });
}
}
}
Expand Up @@ -10,7 +10,7 @@
using System.Threading.Tasks;
using Xunit;

namespace Opw.PineBlog.Posts
namespace Opw.PineBlog.Posts.Search
{
public class SearchPostsQueryTests : MediatRTestsBase
{
Expand Down
@@ -0,0 +1,51 @@
using FluentAssertions;
using Xunit;

namespace Opw.PineBlog.Posts.Search
{
public class SearchQueryExtensionsTests
{
[Fact]
public void ParseTerms_Should_ConvertMultipleSpaceIntoOne_ForQueryNull()
{
string query = null;

var result = query.ParseTerms();

result.Should().HaveCount(0);
}

[Fact]
public void ParseTerms_Should_ConvertMultipleSpaceIntoOne_ForQuery()
{
var query = "c# dotnet pineblog ";

var result = query.ParseTerms();

result.Should().HaveCount(3);
result.Should().BeEquivalentTo(new string[] { "c#", "dotnet", "pineblog" });
}

[Fact]
public void ParseTerms_Should_ToLower_ForQuery()
{
var query = " C# DOTNET pineblog ";

var result = query.ParseTerms();

result.Should().HaveCount(3);
result.Should().BeEquivalentTo(new string[] { "c#", "dotnet", "pineblog" });
}

[Fact]
public void ParseTerms_Should_3Terms_ForQuery()
{
var query = " C# DOTNET pineblog ";

var result = query.ParseTerms();

result.Should().HaveCount(3);
result.Should().BeEquivalentTo(new string[] { "c#", "dotnet", "pineblog" });
}
}
}

0 comments on commit 81ff960

Please sign in to comment.