Skip to content

Commit

Permalink
Merge pull request #14 from DFE-Digital/AW-CL/Refactor-part-word-search
Browse files Browse the repository at this point in the history
Aw cl/refactor part word search
  • Loading branch information
spanersoraferty authored Oct 1, 2024
2 parents ab52573 + ae09cb7 commit ff4009c
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 13 deletions.
10 changes: 10 additions & 0 deletions Dfe.Data.Common.Infrastructure.CognitiveSearch/CompositionRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.SearchRules;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch;

Expand Down Expand Up @@ -65,6 +67,14 @@ public static void AddDefaultCognitiveSearchServices(this IServiceCollection ser
.GetSection(nameof(GeoLocationOptions))
.Bind(settings));

// Register the IOptions object
services.Configure<SearchRuleOptions>(configuration.GetSection("SearchRuleOptions"));
// Explicitly register the settings object by delegating to the IOptions object
services.AddSingleton(resolver =>
resolver.GetRequiredService<IOptions<SearchRuleOptions>>().Value);

services.AddSingleton<ISearchRule, PartialWordMatchRule>();

services.AddHttpClient("GeoLocationHttpClient", config =>
{
var geoLocationOptions =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Azure;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers;
using Microsoft.Extensions.Options;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;

Expand All @@ -12,6 +14,7 @@ namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;
public sealed class DefaultSearchByKeywordService : ISearchByKeywordService
{
private readonly ISearchByKeywordClientProvider _searchClientProvider;
private readonly ISearchRule? _searchRule;

/// <summary>
/// The following T:Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers.ISearchByKeywordClientProvider
Expand All @@ -32,6 +35,29 @@ public DefaultSearchByKeywordService(
throw new ArgumentNullException(nameof(searchClientProvider));
}

/// <summary>
/// The following T:Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers.ISearchByKeywordClientProvider
/// dependency Provides a contract by which to derive (by index name) a configured T:Azure.Search.Documents.SearchClient
/// for use when making search by key-word requests.
/// </summary>
/// <param name="searchClientProvider">
/// The T:Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers.ISearchByKeywordClientProvider instance
/// used to provision a configured Azure search client provider.
/// </param>
/// <param name="searchRule">
/// The implementation of <see cref="ISearchRule"/>
/// </param>
/// <exception cref="ArgumentNullException">
/// The exception thrown when an attempt is made to inject a null search client provider.
/// </exception>
public DefaultSearchByKeywordService(
ISearchByKeywordClientProvider searchClientProvider,
ISearchRule searchRule) : this(searchClientProvider)
{
_searchRule = searchRule;
}


/// <summary>
/// Makes a call to the underlying azure search service client using
/// the prescribed search keyword and options request, and returns a
Expand Down Expand Up @@ -71,6 +97,8 @@ public Task<Response<SearchResults<TSearchResult>>> SearchAsync<TSearchResult>(
ArgumentException.ThrowIfNullOrEmpty(searchIndex);
ArgumentNullException.ThrowIfNull(searchOptions);

searchKeyword = _searchRule?.ApplySearchRules(searchKeyword) ?? searchKeyword;

return InvokeSearch(
searchIndex, (searchClient) =>
searchClient.SearchAsync<TSearchResult>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;

/// <summary>
/// Definition of a provider of search rule that can be used by the search implementation
/// </summary>
public interface ISearchRule
{
/// <summary>
/// Apply optional search rules set in the <see cref="SearchRuleOptions"/> to the search keyword
/// </summary>
/// <param name="keyword">
/// the search keyword
/// </param>
/// <returns></returns>
public string ApplySearchRules(string keyword);
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;

/// <summary>
/// Configuration options used to define how the search should be performed
/// </summary>
public sealed class SearchRuleOptions
{
/// <summary>
/// The name of the search rule to be used.
/// </summary>
public string? SearchRule { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;
using System.Text;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.SearchRules;

/// <summary>
/// Facilitates search rules to be specified when running a search
/// </summary>
public class PartialWordMatchRule : ISearchRule
{
private SearchRuleOptions _ruleOptions;

/// <summary>
/// Construct the saerch rules provider, injecting into it the <see cref="SearchRuleOptions"/> to be applied
/// </summary>
/// <param name="searchRuleOptions"></param>
public PartialWordMatchRule(SearchRuleOptions searchRuleOptions)
{
_ruleOptions = searchRuleOptions;
}

/// <summary>
/// Apply search rules as specified in the options
/// </summary>
/// <param name="searchKeyword"></param>
/// <returns></returns>
public string ApplySearchRules(string searchKeyword)
{
if (_ruleOptions.SearchRule == "PartialWordMatch") // only 1 search rule so far
{
return new StringBuilder(searchKeyword.TrimEnd())
.Replace(" ", "* ")
.Append('*')
.ToString();
}
return searchKeyword;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;
using Moq;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.Tests.Filtering.TestDoubles;

public static class PartialWordMatchRuleTestDouble
{
public static ISearchRule MockFor(string keywordIn, string keywordOut)
{
var mock = new Mock<ISearchRule>();
mock.Setup(provider => provider.ApplySearchRules(keywordIn))
.Returns(keywordOut)
.Verifiable();
return mock.Object;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Azure.Search.Documents.Models;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Providers;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.Tests.Filtering.TestDoubles;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.Tests.Search.TestDoubles;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.Tests.SearchByKeyword.TestDoubles;
using Moq;
Expand All @@ -16,7 +17,7 @@ public class DefaultSearchByKeywordServiceTests
private readonly Mock<SearchClient> _azureSearchClientMock = new();

[Fact]
public async Task SearchAsync_ReturnsExpected()
public async Task SearchAsync_NoProvider_UsesUnmodifiedSearchTerm()
{
// arrange
const string searchIndex = "index1";
Expand All @@ -35,6 +36,57 @@ public async Task SearchAsync_ReturnsExpected()
// act
var result = (await searchService.SearchAsync<TestDocument>(searchKeyword, searchIndex, searchOptions)).Value.GetResults();

// assert
_azureSearchClientMock.Verify(search => search.SearchAsync<TestDocument>(searchKeyword, It.IsAny<SearchOptions>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task SearchAsync_CallsSearchRule()
{
// arrange
const string searchIndex = "index1";
const string searchKeyword = "name";
const string searchKeywordOut = "name*";
const string documentContentValue = "example name";
var mockSearchRule = PartialWordMatchRuleTestDouble.MockFor(searchKeyword, searchKeywordOut);

SearchOptions searchOptions = AzureSearchOptionsTestDouble.SearchOptionsWithSearchField(It.IsAny<string>());
var searchResults = SearchResultsTestDouble<TestDocument>.SearchResultsWith(new TestDocument() { Name = documentContentValue });
_searchClientProviderMock.Setup(provider => provider.InvokeSearchClientAsync(It.IsAny<string>()))
.ReturnsAsync(_azureSearchClientMock.Object);
_azureSearchClientMock.Setup(client => client.SearchAsync<TestDocument>(It.IsAny<string>(), searchOptions, It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(searchResults, new Mock<Response>().Object));

var searchService = new DefaultSearchByKeywordService(_searchClientProviderMock.Object, mockSearchRule);

// act
var result = (await searchService.SearchAsync<TestDocument>(searchKeyword, searchIndex, searchOptions)).Value.GetResults();

// assert
Mock.Get(mockSearchRule).Verify(searchRuleProvider => searchRuleProvider.ApplySearchRules(searchKeyword), Times.Once);
_azureSearchClientMock.Verify(search => search.SearchAsync<TestDocument>(searchKeywordOut, It.IsAny<SearchOptions>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task SearchAsync_ReturnsExpected()
{
// arrange
const string searchIndex = "index1";
const string searchKeyword = "name";
const string documentContentValue = "example name";

SearchOptions searchOptions = AzureSearchOptionsTestDouble.SearchOptionsWithSearchField(searchKeyword);
var searchResults = SearchResultsTestDouble<TestDocument>.SearchResultsWith(new TestDocument() { Name = documentContentValue });
_azureSearchClientMock
.Setup(client => client.SearchAsync<TestDocument>(searchKeyword, searchOptions, It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(searchResults, new Mock<Response>().Object));
_searchClientProviderMock.Setup(provider => provider.InvokeSearchClientAsync(It.IsAny<string>()))
.ReturnsAsync(_azureSearchClientMock.Object);
var searchService = new DefaultSearchByKeywordService(_searchClientProviderMock.Object);

// act
var result = (await searchService.SearchAsync<TestDocument>(searchKeyword, searchIndex, searchOptions)).Value.GetResults();

// assert
var firstPageResult = Assert.IsType<SearchResult<TestDocument>>(result.First());
Assert.Equal(documentContentValue, firstPageResult.Document.Name);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.Options;
using Dfe.Data.Common.Infrastructure.CognitiveSearch.SearchByKeyword.SearchRules;
using FluentAssertions;
using Xunit;

namespace Dfe.Data.Common.Infrastructure.CognitiveSearch.Tests.SearchByKeyword.Providers;

public class PartialWordMatchRuleTests
{
[Theory]
[InlineData("searchKeyword", "searchKeyword*")]
[InlineData("searchKeyword ", "searchKeyword*")]
[InlineData("searchTerm1 searchTerm2", "searchTerm1* searchTerm2*")]
void ApplySearchRules_WithPartialWordMatchRuleOption_AppliesRule(string searchInput, string expected)
{
// arrange
var searchRulesOptions = new SearchRuleOptions()
{
SearchRule = "PartialWordMatch"
};

var provider = new CognitiveSearch.SearchByKeyword.SearchRules.PartialWordMatchRule(searchRulesOptions);

// act
var searchKeywordResult = provider.ApplySearchRules(searchInput);

// assert
searchKeywordResult.Should().Be(expected);
searchInput.Should().NotBe(expected);
}

[Fact]
void ApplySearchRules_WithNotApplicableRuleOption_ReturnsUnmodified()
{
// arrange
var searchRulesOptions = new SearchRuleOptions()
{
SearchRule = "a nonexistent rule"
};

var provider = new PartialWordMatchRule(searchRulesOptions);

// act
var searchKeywordResult = provider.ApplySearchRules("searchKeyword");

// assert
searchKeywordResult.Should().Be("searchKeyword");
}

[Fact]
void ApplySearchRules_WithNoOption_ReturnsUnmodified()
{
// arrange
var searchRulesOptions = new SearchRuleOptions();

var provider = new PartialWordMatchRule(searchRulesOptions);

// act
var searchKeywordResult = provider.ApplySearchRules("searchKeyword");

// assert
searchKeywordResult.Should().Be("searchKeyword");
}
}

0 comments on commit ff4009c

Please sign in to comment.