diff --git a/Dfe.Data.SearchPrototype/Infrastructure/Builders/ISearchOptionsBuilder.cs b/Dfe.Data.SearchPrototype/Infrastructure/Builders/ISearchOptionsBuilder.cs index e409e45..85dbc00 100644 --- a/Dfe.Data.SearchPrototype/Infrastructure/Builders/ISearchOptionsBuilder.cs +++ b/Dfe.Data.SearchPrototype/Infrastructure/Builders/ISearchOptionsBuilder.cs @@ -21,6 +21,19 @@ public interface ISearchOptionsBuilder /// ISearchOptionsBuilder WithSize(int? size); + /// + /// Sets the value used to define how many + /// records are skipped in the search response (if any), + /// by default we have an offset of zero and so choose not to skip any records. + /// + /// + /// The number of initial search results to skip, none (zero) by default . + /// + /// + /// The updated builder instance. + /// + ISearchOptionsBuilder WithOffset(int offset = 0); + /// /// Sets the mode of search to invoke, i.e. All or Any. /// diff --git a/Dfe.Data.SearchPrototype/Infrastructure/Builders/SearchOptionsBuilder.cs b/Dfe.Data.SearchPrototype/Infrastructure/Builders/SearchOptionsBuilder.cs index 05b64ad..bfb43f1 100644 --- a/Dfe.Data.SearchPrototype/Infrastructure/Builders/SearchOptionsBuilder.cs +++ b/Dfe.Data.SearchPrototype/Infrastructure/Builders/SearchOptionsBuilder.cs @@ -16,6 +16,7 @@ public sealed class SearchOptionsBuilder : ISearchOptionsBuilder private SearchMode? _searchMode; private int? _size; + private int _offset; private bool? _includeTotalCount; private IList? _searchFields; private IList? _facets; @@ -53,6 +54,23 @@ public ISearchOptionsBuilder WithSize(int? size) return this; } + /// + /// Sets the value used to define how many + /// records are skipped in the search response (if any), + /// by default we have an offset of zero and so choose not to skip any records. + /// + /// + /// The number of initial search results to skip, none (zero) by default . + /// + /// + /// The updated builder instance. + /// + public ISearchOptionsBuilder WithOffset(int offset = 0) + { + _offset = offset; + return this; + } + /// /// Sets the mode of search to invoke, i.e. All or Any. /// @@ -139,6 +157,7 @@ public SearchOptions Build() { _searchOptions.SearchMode = _searchMode; _searchOptions.Size = _size; + _searchOptions.Skip = _offset; _searchOptions.IncludeTotalCount = _includeTotalCount; _searchFields?.ToList().ForEach(_searchOptions.SearchFields.Add); _facets?.ToList().ForEach(_searchOptions.Facets.Add); diff --git a/Dfe.Data.SearchPrototype/Infrastructure/CognitiveSearchServiceAdapter.cs b/Dfe.Data.SearchPrototype/Infrastructure/CognitiveSearchServiceAdapter.cs index 249be71..8cfe377 100644 --- a/Dfe.Data.SearchPrototype/Infrastructure/CognitiveSearchServiceAdapter.cs +++ b/Dfe.Data.SearchPrototype/Infrastructure/CognitiveSearchServiceAdapter.cs @@ -82,6 +82,7 @@ public async Task SearchAsync(SearchServiceAdapterRequest searchS _searchOptionsBuilder .WithSearchMode((SearchMode)_azureSearchOptions.SearchMode) .WithSize(_azureSearchOptions.Size) + .WithOffset(searchServiceAdapterRequest.Offset) .WithIncludeTotalCount(_azureSearchOptions.IncludeTotalCount) .WithSearchFields(searchServiceAdapterRequest.SearchFields) .WithFacets(searchServiceAdapterRequest.Facets) @@ -100,10 +101,12 @@ await _searchByKeywordService.SearchAsync( var results = new SearchResults() { - Establishments = _searchResultMapper.MapFrom(searchResults.Value.GetResults()), + Establishments = + _searchResultMapper.MapFrom(searchResults.Value.GetResults()), Facets = searchResults.Value.Facets != null ? _facetsMapper.MapFrom(searchResults.Value.Facets.ToDictionary()) - : null + : null, + TotalNumberOfEstablishments = searchResults.Value.TotalCount }; return results; diff --git a/Dfe.Data.SearchPrototype/Infrastructure/Mappers/PageableSearchResultsToEstablishmentResultsMapper.cs b/Dfe.Data.SearchPrototype/Infrastructure/Mappers/PageableSearchResultsToEstablishmentResultsMapper.cs index 6fff1cd..3ba464e 100644 --- a/Dfe.Data.SearchPrototype/Infrastructure/Mappers/PageableSearchResultsToEstablishmentResultsMapper.cs +++ b/Dfe.Data.SearchPrototype/Infrastructure/Mappers/PageableSearchResultsToEstablishmentResultsMapper.cs @@ -7,12 +7,12 @@ namespace Dfe.Data.SearchPrototype.Infrastructure.Mappers; /// -/// Facilitates mapping from the received -/// into the required object. +/// Facilitates mapping from the received +/// into the required object. /// -public sealed class PageableSearchResultsToEstablishmentResultsMapper : IMapper>, Models.EstablishmentResults> +public sealed class PageableSearchResultsToEstablishmentResultsMapper : IMapper>, Models.EstablishmentResults> { - private readonly IMapper _azureSearchResultToEstablishmentMapper; + private readonly IMapper _azureSearchResultToEstablishmentMapper; /// /// The following mapping dependency provides the functionality to map from a raw Azure @@ -22,7 +22,7 @@ public sealed class PageableSearchResultsToEstablishmentResultsMapper : IMapper< /// /// Mapper used to map from the raw Azure search result to a instance. /// - public PageableSearchResultsToEstablishmentResultsMapper(IMapper azureSearchResultToEstablishmentMapper) + public PageableSearchResultsToEstablishmentResultsMapper(IMapper azureSearchResultToEstablishmentMapper) { _azureSearchResultToEstablishmentMapper = azureSearchResultToEstablishmentMapper; } @@ -44,9 +44,10 @@ public PageableSearchResultsToEstablishmentResultsMapper(IMapper /// Exception thrown if the data cannot be mapped /// - public Models.EstablishmentResults MapFrom(Pageable> input) + public Models.EstablishmentResults MapFrom(Pageable> input) { ArgumentNullException.ThrowIfNull(input); + Models.EstablishmentResults establishmentResults = new(); if (input.Any()) { @@ -54,13 +55,12 @@ public Models.EstablishmentResults MapFrom(Pageable> searchResultDocuments = SearchResultFake.SearchResults(); + var pageableSearchResults = PageableTestDouble.FromResults(searchResultDocuments); // act @@ -36,7 +36,8 @@ public void MapFrom_WithValidSearchResults_ReturnsConfiguredEstablishments() // assert mappedResult.Should().NotBeNull(); - mappedResult.Establishments.Should().HaveCount(searchResultDocuments.Count()); + mappedResult.Establishments.Should().HaveCount(searchResultDocuments.Count); + foreach (var searchResult in searchResultDocuments) { searchResult.ShouldHaveMatchingMappedEstablishment(mappedResult); @@ -47,7 +48,9 @@ public void MapFrom_WithValidSearchResults_ReturnsConfiguredEstablishments() public void MapFrom_WithEmptySearchResults_ReturnsEmptyList() { // act - EstablishmentResults? result = _searchResultsMapper.MapFrom(PageableTestDouble.FromResults(SearchResultFake.EmptySearchResult())); + EstablishmentResults? result = + _searchResultsMapper.MapFrom( + PageableTestDouble.FromResults(SearchResultFake.EmptySearchResult())); // assert result.Should().NotBeNull(); diff --git a/Dfe.Data.SearchPrototype/Infrastructure/Tests/TestDoubles/SearchOptionsBuilderTestDouble.cs b/Dfe.Data.SearchPrototype/Infrastructure/Tests/TestDoubles/SearchOptionsBuilderTestDouble.cs index 999da1c..04d6a44 100644 --- a/Dfe.Data.SearchPrototype/Infrastructure/Tests/TestDoubles/SearchOptionsBuilderTestDouble.cs +++ b/Dfe.Data.SearchPrototype/Infrastructure/Tests/TestDoubles/SearchOptionsBuilderTestDouble.cs @@ -13,7 +13,9 @@ public static ISearchOptionsBuilder MockFor(SearchOptions searchOptions) var mockSearchOptionsBuilder = new Mock(); mockSearchOptionsBuilder.Setup(searchOptionsBuilder => - searchOptionsBuilder.WithSize(It.IsAny())).Returns(mockSearchOptionsBuilder.Object); + searchOptionsBuilder.WithSize(It.IsAny())).Returns(mockSearchOptionsBuilder.Object); + mockSearchOptionsBuilder.Setup(searchOptionsBuilder => + searchOptionsBuilder.WithOffset(It.IsAny())).Returns(mockSearchOptionsBuilder.Object); mockSearchOptionsBuilder.Setup(searchOptionsBuilder => searchOptionsBuilder.WithSearchMode(It.IsAny())).Returns(mockSearchOptionsBuilder.Object); mockSearchOptionsBuilder.Setup(searchOptionsBuilder => diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequest.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequest.cs index 1b31fd2..5fdfcfd 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequest.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequest.cs @@ -13,6 +13,12 @@ public sealed class SearchServiceAdapterRequest /// public string SearchKeyword { get; } + /// + /// The value used to define how many records are skipped in the search response (if any), + /// by default we have an offset of zero and so choose not to skip any records. + /// + public int Offset { get; } = 0; + /// /// The collection of fields in the underlying collection to search over. /// @@ -44,6 +50,10 @@ public sealed class SearchServiceAdapterRequest /// /// Dictionary of search filter requests where key is the name of the filter and the value is the list of filter values. /// + /// + /// The value used to define how many records are skipped in the search response + /// (if any), by default we choose not to skip any records. + /// /// /// The exception thrown if an invalid search keyword (null or whitespace) is prescribed. /// @@ -51,37 +61,25 @@ public sealed class SearchServiceAdapterRequest /// The exception type thrown if either a null or empty collection of search fields, /// or search facets are prescribed. /// - public SearchServiceAdapterRequest(string searchKeyword, IList searchFields, IList facets, IList? searchFilterRequests = null) + public SearchServiceAdapterRequest( + string searchKeyword, + IList searchFields, + IList facets, + IList? searchFilterRequests = null, + int offset = 0) { SearchKeyword = string.IsNullOrWhiteSpace(searchKeyword) ? throw new ArgumentNullException(nameof(searchKeyword)) : searchKeyword; SearchFields = searchFields == null || searchFields.Count <= 0 ? - throw new ArgumentException("", nameof(searchFields)) : searchFields; + throw new ArgumentException($"A valid {nameof(searchFields)} argument must be provided.") : searchFields; Facets = facets == null || facets.Count <= 0 ? - throw new ArgumentException("", nameof(facets)) : facets; + throw new ArgumentException($"A valid {nameof(facets)} argument must be provided.") : facets; SearchFilterRequests = searchFilterRequests; - } - /// - /// Factory method to allow implicit creation of a T:Dfe.Data.SearchPrototype.Search.SearchContext instance. - /// - /// - /// The keyword string which defines the search. - /// - /// - /// The collection of fields in the underlying collection to search over. - /// - /// - /// The collection of facets to apply in the search request. - /// - /// - /// A configured instance. - /// - public static SearchServiceAdapterRequest Create( - string searchKeyword, IList searchFields, IList facets) => - new(searchKeyword, searchFields, facets); + Offset = offset; + } } diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordRequest.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordRequest.cs index 0dc7d39..44bd97b 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordRequest.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordRequest.cs @@ -14,11 +14,16 @@ public sealed class SearchByKeywordRequest /// /// The string keyword used to search the collection specified. /// - public SearchByKeywordRequest(string searchKeyword) + /// + /// The value used to define how many records are skipped in the search + /// response (if any), by default we choose not to skip any records. + /// + public SearchByKeywordRequest(string searchKeyword, int offset = 0) { ArgumentException.ThrowIfNullOrEmpty(nameof(searchKeyword)); SearchKeyword = searchKeyword; + Offset = offset; } /// @@ -32,7 +37,11 @@ public SearchByKeywordRequest(string searchKeyword) /// /// The used to refine the search criteria. /// - public SearchByKeywordRequest(string searchKeyword, IList filterRequests) : this(searchKeyword) + /// + /// The value used to define how many records are skipped in the search response (if any). + /// + public SearchByKeywordRequest( + string searchKeyword,IList filterRequests, int offset = 0) : this(searchKeyword, offset) { FilterRequests = filterRequests; } @@ -42,6 +51,11 @@ public SearchByKeywordRequest(string searchKeyword, IList filterR /// public string SearchKeyword { get; } + /// + /// The value used to define how many records are skipped in the search response (if any). + /// + public int Offset { get; } + /// /// The filter (key/values) used to refine the search criteria. /// diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordResponse.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordResponse.cs index e5608e5..a595788 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordResponse.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordResponse.cs @@ -47,7 +47,11 @@ public SearchByKeywordResponse(SearchResponseStatus status) /// /// The of the result of the search. /// - public SearchByKeywordResponse(EstablishmentResults establishments, EstablishmentFacets facetResults, SearchResponseStatus status) + public SearchByKeywordResponse( + EstablishmentResults establishments, + EstablishmentFacets facetResults, + SearchResponseStatus status + ) { EstablishmentResults = establishments; EstablishmentFacetResults = facetResults; diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordUseCase.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordUseCase.cs index 98a5ec2..f48540a 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordUseCase.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/ByKeyword/Usecase/SearchByKeywordUseCase.cs @@ -78,7 +78,8 @@ await _searchServiceAdapter.SearchAsync( request.SearchKeyword, _searchByKeywordCriteria.SearchFields, _searchByKeywordCriteria.Facets, - request.FilterRequests)); + request.FilterRequests, + request.Offset)); return results switch { diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/EstablishmentResults.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/EstablishmentResults.cs index 3e950e4..7d7bda1 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/EstablishmentResults.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/EstablishmentResults.cs @@ -6,14 +6,14 @@ /// public sealed class EstablishmentResults { + private readonly List _establishments; + /// /// The readonly collection of /// types derived from the underlying search mechanism. /// public IReadOnlyCollection Establishments => _establishments.AsReadOnly(); - private readonly List _establishments; - /// /// Default constructor initialises a new readonly /// collection of instances. diff --git a/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/SearchResults.cs b/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/SearchResults.cs index 86a77dc..8653abf 100644 --- a/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/SearchResults.cs +++ b/Dfe.Data.SearchPrototype/SearchForEstablishments/Models/SearchResults.cs @@ -19,4 +19,10 @@ public class SearchResults /// that is built from the underlying search response. /// public EstablishmentFacets? Facets { get; init; } + + /// + /// The Total Count returned from Establishment search gives us a total + /// of all available records which correlates with the given search criteria. + /// + public long? TotalNumberOfEstablishments { get; init; } } diff --git a/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/SearchByKeywordRequestTests.cs b/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/SearchByKeywordRequestTests.cs index 09b41dc..340dcb5 100644 --- a/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/SearchByKeywordRequestTests.cs +++ b/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/SearchByKeywordRequestTests.cs @@ -37,4 +37,28 @@ public void Constructor_WithNoFilterParam_HasFilterRequestsNull() // assert request.FilterRequests.Should().BeNull(); } + + [Fact] + public void Constructor_WithSetOffsetValue_AssignsCorrectPropertyValue() + { + //arrange + const int Offset = 10; + // act + var request = new SearchByKeywordRequest("searchKeyword", Offset); + + // assert + request.Offset. + Should().Be(Offset); + } + + [Fact] + public void Constructor_WithDefaultOffsetValue_AssignsDefaultPropertyValue() + { + // act + var request = new SearchByKeywordRequest("searchKeyword"); + + // assert + request.Offset. + Should().Be(0);//value of zero ensures no records are skipped + } } diff --git a/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequestTests.cs b/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequestTests.cs new file mode 100644 index 0000000..6e90717 --- /dev/null +++ b/Dfe.Data.SearchPrototype/Tests/SearchForEstablishments/ByKeyword/ServiceAdapters/SearchServiceAdapterRequestTests.cs @@ -0,0 +1,108 @@ +using Dfe.Data.SearchPrototype.SearchForEstablishments.ByKeyword.ServiceAdapters; +using Dfe.Data.SearchPrototype.SearchForEstablishments.ByKeyword.Usecase; +using FluentAssertions; +using Xunit; + +namespace Dfe.Data.SearchPrototype.Tests.SearchForEstablishments.ByKeyword.ServiceAdapters; + +public class SearchServiceAdapterRequestTests +{ + [Fact] + public void Constructor_WithNoFilterParam_AssignsCorrectPropertyValues() + { + // act + var request = new SearchServiceAdapterRequest( + searchKeyword:"searchKeyword", + searchFields:["ESTABLISHMENTNAME", "TOWN"], + facets:["facet1", "facet2"], + offset: 10 + ); + + // assert + request.SearchKeyword.Should().Be("searchKeyword"); + request.SearchFields[0].Should().Be("ESTABLISHMENTNAME"); + request.SearchFields.Count().Should().Be(2); + request.Facets.Contains("facet2"); + request.Facets.Should().HaveCount(2); + request.Offset.Should().Be(10); + request.SearchFilterRequests.Should().BeNull(); + } + + [Fact] + public void Constructor_WithFilterParam_PopulatesFilterRequests() + { + // arrange + var filterList = new List() + { + new FilterRequest("EducationPhase", new List() {"Primary", "Secondary"}), + new FilterRequest("MaybeATypeCode", new List() {1,2}) + }; + + // act + var request = new SearchServiceAdapterRequest( + searchKeyword :"searchKeyword", + searchFields: ["ESTABLISHMENTNAME", "TOWN"], + facets: ["facet1", "facet2"], + searchFilterRequests: filterList + ); + + // assert + request.SearchFilterRequests.Should().NotBeNull(); + foreach (var item in filterList) + { + var matchingRequest = request.SearchFilterRequests!.First(x => x.FilterName == item.FilterName); + matchingRequest.FilterValues.Should().BeEquivalentTo(item.FilterValues); + } + } + + [Fact] + public void Constructor_WithNullSearchKeyword_ThrowsArgumentNullException() + { + // act + Action request =() => new SearchServiceAdapterRequest( + searchKeyword: "", + searchFields : ["ESTABLISHMENTNAME", "TOWN"], + facets : ["facet1", "facet2"], + offset: 10 + ); + + // assert + request.Should().Throw() + .WithMessage("Value cannot be null. (Parameter 'searchKeyword')") + .And.ParamName.Should().Be("searchKeyword"); + } + + [Fact] + public void Constructor_WithNullSearchFields_ThrowsArgumentException() + { + // act + Action request = () => + new SearchServiceAdapterRequest( + searchKeyword: "searchKeyword", + searchFields: null!, + facets: ["facet1", "facet2"], + offset: 10 + ); + + // assert + request.Should().Throw() + .WithMessage("A valid searchFields argument must be provided."); + } + + [Fact] + public void Constructor_WithNullFacets_ThrowsArgumentException() + { + // act + Action request = () => + new SearchServiceAdapterRequest( + searchKeyword:"searchKeyword", + searchFields: ["ESTABLISHMENTNAME", "TOWN"], + facets: null!, + offset: 10 + ); + + // assert + request.Should().Throw() + .WithMessage("A valid facets argument must be provided."); + } +}