From f1763618c8cbc468644e067b8b8ce187c1f6a5c9 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Mon, 12 Dec 2022 21:43:58 +1300 Subject: [PATCH 01/16] Support for deep paging using search after --- src/Examine.Core/EmptySearchResults.cs | 6 +- src/Examine.Core/Search/QueryOptions.cs | 7 ++ .../Search/ILuceneSearchResults.cs | 7 ++ .../Search/LuceneQueryOptions.cs | 27 +++++ .../Search/LuceneSearchExecutor.cs | 101 ++++++++++++++---- .../Search/LuceneSearchResult.cs | 19 ++++ .../Search/LuceneSearchResults.cs | 11 +- .../Search/SearchAfterOptions.cs | 39 +++++++ .../Examine.Lucene/Search/FluentApiTests.cs | 65 +++++++++++ 9 files changed, 254 insertions(+), 28 deletions(-) create mode 100644 src/Examine.Lucene/Search/ILuceneSearchResults.cs create mode 100644 src/Examine.Lucene/Search/LuceneQueryOptions.cs create mode 100644 src/Examine.Lucene/Search/LuceneSearchResult.cs create mode 100644 src/Examine.Lucene/Search/SearchAfterOptions.cs diff --git a/src/Examine.Core/EmptySearchResults.cs b/src/Examine.Core/EmptySearchResults.cs index 77d5763dc..bbaea7358 100644 --- a/src/Examine.Core/EmptySearchResults.cs +++ b/src/Examine.Core/EmptySearchResults.cs @@ -24,7 +24,9 @@ IEnumerator IEnumerable.GetEnumerator() public long TotalItemCount => 0; - public IEnumerable Skip(int skip) + public string ContinueWith => default; + + public IEnumerable Skip(int skip) { return Enumerable.Empty(); } @@ -34,4 +36,4 @@ public IEnumerable SkipTake(int skip, int? take = null) return Enumerable.Empty(); } } -} \ No newline at end of file +} diff --git a/src/Examine.Core/Search/QueryOptions.cs b/src/Examine.Core/Search/QueryOptions.cs index ec026eaf2..50f2a3756 100644 --- a/src/Examine.Core/Search/QueryOptions.cs +++ b/src/Examine.Core/Search/QueryOptions.cs @@ -24,7 +24,14 @@ public QueryOptions(int skip, int? take = null) Take = take ?? DefaultMaxResults; } + /// + /// The number of documents to skip in the result set. + /// public int Skip { get; } + + /// + /// The number of documents to take in the result set. + /// public int Take { get; } } } diff --git a/src/Examine.Lucene/Search/ILuceneSearchResults.cs b/src/Examine.Lucene/Search/ILuceneSearchResults.cs new file mode 100644 index 000000000..e1214756a --- /dev/null +++ b/src/Examine.Lucene/Search/ILuceneSearchResults.cs @@ -0,0 +1,7 @@ +namespace Examine.Lucene.Search +{ + public interface ILuceneSearchResults : ISearchResults + { + SearchAfterOptions SearchAfter { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneQueryOptions.cs b/src/Examine.Lucene/Search/LuceneQueryOptions.cs new file mode 100644 index 000000000..32fd2765e --- /dev/null +++ b/src/Examine.Lucene/Search/LuceneQueryOptions.cs @@ -0,0 +1,27 @@ +using Examine.Search; + +namespace Examine.Lucene.Search +{ + /// + /// Lucene.NET specific query options + /// + public class LuceneQueryOptions : QueryOptions + { + /// + /// Constructor + /// + /// Number of result documents to skip. + /// Optional number of result documents to take. + /// Optionally skip to results after the results from the previous search execution. Used for efficent deep paging. + public LuceneQueryOptions(int skip, int? take = null, SearchAfterOptions searchAfter = null) + : base(skip, take) + { + SearchAfter = searchAfter; + } + + /// + /// Options for Searching After. Used for efficent deep paging. + /// + public SearchAfterOptions SearchAfter { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 1ef6f0e1e..136c5331f 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -15,6 +15,7 @@ namespace Examine.Lucene.Search public class LuceneSearchExecutor { private readonly QueryOptions _options; + private readonly LuceneQueryOptions _luceneQueryOptions; private readonly IEnumerable _sortField; private readonly ISearchContext _searchContext; private readonly Query _luceneQuery; @@ -24,6 +25,7 @@ public class LuceneSearchExecutor internal LuceneSearchExecutor(QueryOptions options, Query query, IEnumerable sortField, ISearchContext searchContext, ISet fieldsToLoad) { _options = options ?? QueryOptions.Default; + _luceneQueryOptions = _options as LuceneQueryOptions; _luceneQuery = query ?? throw new ArgumentNullException(nameof(query)); _fieldsToLoad = fieldsToLoad; _sortField = sortField ?? throw new ArgumentNullException(nameof(sortField)); @@ -78,31 +80,81 @@ public ISearchResults Execute() var maxResults = Math.Min((_options.Skip + 1) * _options.Take, MaxDoc); maxResults = maxResults >= 1 ? maxResults : QueryOptions.DefaultMaxResults; + int numHits = maxResults; - ICollector topDocsCollector; SortField[] sortFields = _sortField as SortField[] ?? _sortField.ToArray(); - if (sortFields.Length > 0) - { - topDocsCollector = TopFieldCollector.Create( - new Sort(sortFields), maxResults, false, false, false, false); - } - else - { - topDocsCollector = TopScoreDocCollector.Create(maxResults, true); - } + + bool doSearchAfter = false; + FieldDoc scoreDocAfter = null; + bool scoredInOrder = false; using (ISearcherReference searcher = _searchContext.GetSearcher()) { - searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); + Sort sort = null; + if (sortFields.Length > 0) + { + sort = new Sort(sortFields); + sort.Rewrite(searcher.IndexSearcher); + } + if (_luceneQueryOptions != null && _luceneQueryOptions.SearchAfter != null) + { + //The document to find results after. + var searchAfter = _luceneQueryOptions.SearchAfter; + doSearchAfter = true; + + + object[] searchAfterSortFields = new object[0]; + if (_luceneQueryOptions.SearchAfter.Fields != null && _luceneQueryOptions.SearchAfter.Fields.Length > 0) + { + searchAfterSortFields = _luceneQueryOptions.SearchAfter.Fields; + } + if (searchAfter.ShardIndex != null) + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields, searchAfter.ShardIndex.Value); + } + else + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields); + } + + + // We want to only collect only the actual number of hits we want to take after the last document. We don't need to collect all previous docs + numHits = _options.Take >= 1 ? _options.Take : QueryOptions.DefaultMaxResults; + } TopDocs topDocs; - if (sortFields.Length > 0) + if (doSearchAfter) { - topDocs = ((TopFieldCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + topDocs =searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take, sort); + } + else if (sort != null) + { + int topN = numHits; + + topDocs = searcher.IndexSearcher.Search(_luceneQuery, null, topN, sort, true, true); } else { - topDocs = ((TopScoreDocCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + ICollector topDocsCollector; + + if (sortFields.Length > 0) + { + topDocsCollector = TopFieldCollector.Create( + new Sort(sortFields), numHits, scoreDocAfter, false, false, false, scoredInOrder); + } + else + { + topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); + } + searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); + if (sortFields.Length > 0) + { + topDocs = ((TopFieldCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + } + else + { + topDocs = ((TopScoreDocCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); + } } var totalItemCount = topDocs.TotalHits; @@ -113,12 +165,17 @@ public ISearchResults Execute() var result = GetSearchResult(i, topDocs, searcher.IndexSearcher); results.Add(result); } - - return new LuceneSearchResults(results, totalItemCount); + SearchAfterOptions searchAfterOptions = null; + var lastFieldDoc = topDocs.ScoreDocs.LastOrDefault() as FieldDoc; + if (lastFieldDoc != null) + { + searchAfterOptions = new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + } + return new LuceneSearchResults(results, totalItemCount, searchAfterOptions); } } - private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) + private LuceneSearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) { // I have seen IndexOutOfRangeException here which is strange as this is only called in one place // and from that one place "i" is always less than the size of this collection. @@ -141,8 +198,8 @@ private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher doc = luceneSearcher.Doc(docId); } var score = scoreDoc.Score; - var result = CreateSearchResult(doc, score); - + var shardIndex = scoreDoc.ShardIndex; + var result = CreateSearchResult(doc, score, shardIndex); return result; } @@ -152,7 +209,7 @@ private ISearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher /// The doc to convert. /// The score. /// A populated search result object - private ISearchResult CreateSearchResult(Document doc, float score) + private LuceneSearchResult CreateSearchResult(Document doc, float score, int shardIndex) { var id = doc.Get("id"); @@ -161,7 +218,7 @@ private ISearchResult CreateSearchResult(Document doc, float score) id = doc.Get(ExamineFieldNames.ItemIdFieldName); } - var searchResult = new SearchResult(id, score, () => + var searchResult = new LuceneSearchResult(id, score, () => { //we can use lucene to find out the fields which have been stored for this particular document var fields = doc.Fields; @@ -190,7 +247,7 @@ private ISearchResult CreateSearchResult(Document doc, float score) } return resultVals; - }); + }, shardIndex); return searchResult; } diff --git a/src/Examine.Lucene/Search/LuceneSearchResult.cs b/src/Examine.Lucene/Search/LuceneSearchResult.cs new file mode 100644 index 000000000..96f19650c --- /dev/null +++ b/src/Examine.Lucene/Search/LuceneSearchResult.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Examine.Lucene.Search +{ + public class LuceneSearchResult : SearchResult, ISearchResult + { + public LuceneSearchResult(string id, float score, Func>> lazyFieldVals, int shardId) + : base(id, score, lazyFieldVals) + { + ShardIndex = shardId; + } + + public int ShardIndex { get; } + } +} diff --git a/src/Examine.Lucene/Search/LuceneSearchResults.cs b/src/Examine.Lucene/Search/LuceneSearchResults.cs index 92258d940..efe700608 100644 --- a/src/Examine.Lucene/Search/LuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/LuceneSearchResults.cs @@ -1,23 +1,26 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; namespace Examine.Lucene.Search { - public class LuceneSearchResults : ISearchResults + public class LuceneSearchResults : ILuceneSearchResults { - public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0); + public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0, default); private readonly IReadOnlyCollection _results; - public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount) + public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount, SearchAfterOptions searchAfterOptions) { _results = results; TotalItemCount = totalItemCount; + SearchAfter = searchAfterOptions; } public long TotalItemCount { get; } + public SearchAfterOptions SearchAfter { get; } + public IEnumerator GetEnumerator() => _results.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/Examine.Lucene/Search/SearchAfterOptions.cs b/src/Examine.Lucene/Search/SearchAfterOptions.cs new file mode 100644 index 000000000..8a6ae3306 --- /dev/null +++ b/src/Examine.Lucene/Search/SearchAfterOptions.cs @@ -0,0 +1,39 @@ +namespace Examine.Lucene.Search +{ + /// + /// Options for Searching After. Used for efficent deep paging. + /// + public class SearchAfterOptions + { + + public SearchAfterOptions(int documentId, float documentScore, object[] fields, int shardIndex) + { + DocumentId = documentId; + DocumentScore = documentScore; + Fields = fields; + ShardIndex = shardIndex; + } + + /// + /// The Id of the last document in the previous result set. + /// The search will search after this document + /// + public int DocumentId { get; } + + /// + /// The Score of the last document in the previous result set. + /// The search will search after this document + /// + public float DocumentScore { get; } + + /// + /// The index of the shard the doc belongs to + /// + public int? ShardIndex { get; } + + /// + /// Search fields. Should contain null or J2N.Int + /// + public object[] Fields { get; } + } +} diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index 8aac33f6f..48d25b47a 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -2556,6 +2556,71 @@ public void Given_SkipTake_Returns_ExpectedTotals(int skip, int take, int expect } } + [Test] + public void SearchAfter_Results_Returns_Different_Results() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "umbraco", headerText = "world", writerName = "administrator" }), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(4.ToString(), "content", + new { nodeName = "umbraco", headerText = "nz", writerName = "administrator" }), + ValueSet.FromObject(5.ToString(), "content", + new { nodeName = "hello", headerText = "world", writerName = "blah" }) + }); + + var searcher = indexer.Searcher; + + //Arrange + var sc = searcher.CreateQuery("content") + .Field("writerName", "administrator") + .OrderByDescending(new SortableField("id",SortType.Int)); + var luceneOptions = new LuceneQueryOptions(0, 2); + //Act + + //There are 4 results + // First query skips 0 and takes 2. + var results = sc.Execute(luceneOptions); + var luceneResults = results as ILuceneSearchResults; + Assert.IsNotNull(luceneResults); + Assert.IsNotNull(luceneResults.SearchAfter,"Search After details should be available"); + var luceneResults1List = luceneResults.ToList(); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "1")); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "2")); + + // Second query result continues after result 1 (zero indexed), Takes 1, should not include any of the results before or include the SearchAfter docid / scoreid + var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, luceneResults.SearchAfter.DocumentScore, luceneResults.SearchAfter.Fields, luceneResults.SearchAfter.ShardIndex.Value); + var luceneOptions2 = new LuceneQueryOptions(1,1, searchAfter); + var results2 = sc.Execute(luceneOptions2); + var luceneResults2 = results2 as ILuceneSearchResults; + var luceneResults2List = luceneResults2.ToList(); + Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); + Assert.IsNotNull(luceneResults2); + + Assert.IsFalse(luceneResults2List.Any(x => luceneResults.ToList().Any(y => y.Id == x.Id)), "Results should not overlap"); + + // Third query result continues after result 2 (zero indexed), Takes 1 + var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); + var luceneOptions3 = new LuceneQueryOptions(2, 1, searchAfter2); + var results3 = sc.Execute(luceneOptions3); + var luceneResults3 = results3 as ILuceneSearchResults; + Assert.IsNotNull(luceneResults3); + var luceneResults3List = luceneResults3.ToList(); + Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); + + Assert.AreNotEqual(results.First().Id, results2.First().Id, "Results should be different"); + } + } + #if NET6_0_OR_GREATER [Test] public void Range_DateOnly() From df4da35ced9f3e5288cd3525d818459a72360875 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Mon, 12 Dec 2022 21:52:14 +1300 Subject: [PATCH 02/16] Doc --- src/Examine.Lucene/Search/ILuceneSearchResults.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Examine.Lucene/Search/ILuceneSearchResults.cs b/src/Examine.Lucene/Search/ILuceneSearchResults.cs index e1214756a..bef62e7db 100644 --- a/src/Examine.Lucene/Search/ILuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/ILuceneSearchResults.cs @@ -1,7 +1,13 @@ namespace Examine.Lucene.Search { + /// + /// Lucene.NET Search Results + /// public interface ILuceneSearchResults : ISearchResults { + /// + /// Options for Searching After. Used for efficent deep paging. + /// SearchAfterOptions SearchAfter { get; } } } From 70edcc10fd9933baed97e60f867669be5c068aa2 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Mon, 12 Dec 2022 21:59:54 +1300 Subject: [PATCH 03/16] Documentation --- docs/sorting.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/sorting.md b/docs/sorting.md index 5ca9a8301..ae2a320e7 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -96,6 +96,15 @@ With the combination of `ISearchResult.Skip` and `maxResults`, we can tell Lucen * Skip over a certain number of results without allocating them and tell Lucene * only allocate a certain number of results after skipping +### Deep Paging +When using Lucene.NET as the Examine provider it is possible to more efficiently perform deep paging. +Steps: +1. Build and Execute your query as normal. +2. Cast the ISearchResults from IQueryExecutor.Execute to ILuceneSearchResults +3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. It may be worth serializing this class and cryptographically hashing it to prevent tampering in a web application so that it can be made available to the next request for the next page. +4. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip is still relative to the start of the search results. +5. Repeat Steps 2-4 for each page. + ### Example ```cs From fd802e6a0c365a172828deda02d984b7717a3af4 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Mon, 12 Dec 2022 22:02:03 +1300 Subject: [PATCH 04/16] Add note. --- docs/sorting.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/sorting.md b/docs/sorting.md index ae2a320e7..0a9632658 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -99,11 +99,12 @@ With the combination of `ISearchResult.Skip` and `maxResults`, we can tell Lucen ### Deep Paging When using Lucene.NET as the Examine provider it is possible to more efficiently perform deep paging. Steps: -1. Build and Execute your query as normal. +1. Build and execute your query as normal. 2. Cast the ISearchResults from IQueryExecutor.Execute to ILuceneSearchResults 3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. It may be worth serializing this class and cryptographically hashing it to prevent tampering in a web application so that it can be made available to the next request for the next page. -4. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip is still relative to the start of the search results. -5. Repeat Steps 2-4 for each page. +4. Create the same query as the previous request. +5. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip is still relative to the start of the search results. +6. Repeat Steps 2-5 for each page. ### Example From 3a6860b1a0de66bbd8c51a6bccf97a05a3650127 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 00:13:15 +1300 Subject: [PATCH 05/16] Fix collectors. Tidy. --- docs/sorting.md | 2 +- .../Search/ILuceneSearchResults.cs | 6 ++ .../Search/LuceneQueryOptions.cs | 14 ++- .../Search/LuceneSearchExecutor.cs | 98 +++++++++++-------- .../Search/LuceneSearchResults.cs | 11 ++- .../Examine.Lucene/Search/FluentApiTests.cs | 21 ++-- 6 files changed, 96 insertions(+), 56 deletions(-) diff --git a/docs/sorting.md b/docs/sorting.md index 0a9632658..b34e11514 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -103,7 +103,7 @@ Steps: 2. Cast the ISearchResults from IQueryExecutor.Execute to ILuceneSearchResults 3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. It may be worth serializing this class and cryptographically hashing it to prevent tampering in a web application so that it can be made available to the next request for the next page. 4. Create the same query as the previous request. -5. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip is still relative to the start of the search results. +5. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip will be ignored, the next take documents will be retrieved after the SearchAfterOptions document. 6. Repeat Steps 2-5 for each page. ### Example diff --git a/src/Examine.Lucene/Search/ILuceneSearchResults.cs b/src/Examine.Lucene/Search/ILuceneSearchResults.cs index bef62e7db..1f45ea1da 100644 --- a/src/Examine.Lucene/Search/ILuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/ILuceneSearchResults.cs @@ -9,5 +9,11 @@ public interface ILuceneSearchResults : ISearchResults /// Options for Searching After. Used for efficent deep paging. /// SearchAfterOptions SearchAfter { get; } + + /// + /// Returns the maximum score value encountered. Note that in case + /// scores are not tracked, this returns . + /// + float MaxScore { get; } } } diff --git a/src/Examine.Lucene/Search/LuceneQueryOptions.cs b/src/Examine.Lucene/Search/LuceneQueryOptions.cs index 32fd2765e..e28595604 100644 --- a/src/Examine.Lucene/Search/LuceneQueryOptions.cs +++ b/src/Examine.Lucene/Search/LuceneQueryOptions.cs @@ -13,12 +13,24 @@ public class LuceneQueryOptions : QueryOptions /// Number of result documents to skip. /// Optional number of result documents to take. /// Optionally skip to results after the results from the previous search execution. Used for efficent deep paging. - public LuceneQueryOptions(int skip, int? take = null, SearchAfterOptions searchAfter = null) + public LuceneQueryOptions(int skip, int? take = null, SearchAfterOptions searchAfter = null, bool trackDocumentScores = false, bool trackDocumentMaxScore = false) : base(skip, take) { + TrackDocumentScores = trackDocumentScores; + TrackDocumentMaxScore = trackDocumentMaxScore; SearchAfter = searchAfter; } + /// + /// Whether to Track Document Scores. For best performance, if not needed, leave false. + /// + public bool TrackDocumentScores { get; } + + /// + /// Whether to track the maximum document score. For best performance, if not needed, leave false. + /// + public bool TrackDocumentMaxScore { get; } + /// /// Options for Searching After. Used for efficent deep paging. /// diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 136c5331f..b2f84ef4e 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -83,14 +83,12 @@ public ISearchResults Execute() int numHits = maxResults; SortField[] sortFields = _sortField as SortField[] ?? _sortField.ToArray(); - - bool doSearchAfter = false; + Sort sort = null; FieldDoc scoreDocAfter = null; - bool scoredInOrder = false; + Filter filter = null; using (ISearcherReference searcher = _searchContext.GetSearcher()) { - Sort sort = null; if (sortFields.Length > 0) { sort = new Sort(sortFields); @@ -99,53 +97,33 @@ public ISearchResults Execute() if (_luceneQueryOptions != null && _luceneQueryOptions.SearchAfter != null) { //The document to find results after. - var searchAfter = _luceneQueryOptions.SearchAfter; - doSearchAfter = true; - - - object[] searchAfterSortFields = new object[0]; - if (_luceneQueryOptions.SearchAfter.Fields != null && _luceneQueryOptions.SearchAfter.Fields.Length > 0) - { - searchAfterSortFields = _luceneQueryOptions.SearchAfter.Fields; - } - if (searchAfter.ShardIndex != null) - { - scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields, searchAfter.ShardIndex.Value); - } - else - { - scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields); - } - + scoreDocAfter = GetScoreDocAfter(_luceneQueryOptions); - // We want to only collect only the actual number of hits we want to take after the last document. We don't need to collect all previous docs + // We want to only collect only the actual number of hits we want to take after the last document. We don't need to collect all previous/next docs. numHits = _options.Take >= 1 ? _options.Take : QueryOptions.DefaultMaxResults; } TopDocs topDocs; - if (doSearchAfter) + ICollector topDocsCollector; + bool trackMaxScore = _luceneQueryOptions == null ? false : _luceneQueryOptions.TrackDocumentMaxScore; + bool trackDocScores = _luceneQueryOptions == null ? false : _luceneQueryOptions.TrackDocumentScores; + + if (sortFields.Length > 0) { - topDocs =searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take, sort); + bool fillFields = true; + topDocsCollector = TopFieldCollector.Create(sort, numHits, scoreDocAfter, fillFields, trackDocScores, trackMaxScore, false); } - else if (sort != null) + else { - int topN = numHits; + topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); + } - topDocs = searcher.IndexSearcher.Search(_luceneQuery, null, topN, sort, true, true); + if (scoreDocAfter != null) + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, true, trackMaxScore); } else { - ICollector topDocsCollector; - - if (sortFields.Length > 0) - { - topDocsCollector = TopFieldCollector.Create( - new Sort(sortFields), numHits, scoreDocAfter, false, false, false, scoredInOrder); - } - else - { - topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); - } searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); if (sortFields.Length > 0) { @@ -159,20 +137,54 @@ public ISearchResults Execute() var totalItemCount = topDocs.TotalHits; - var results = new List(); + var results = new List(topDocs.ScoreDocs.Length); for (int i = 0; i < topDocs.ScoreDocs.Length; i++) { var result = GetSearchResult(i, topDocs, searcher.IndexSearcher); results.Add(result); } - SearchAfterOptions searchAfterOptions = null; - var lastFieldDoc = topDocs.ScoreDocs.LastOrDefault() as FieldDoc; + var searchAfterOptions = GetSearchAfterOptions(topDocs); + float maxScore = topDocs.MaxScore; + + return new LuceneSearchResults(results, totalItemCount, maxScore, searchAfterOptions); + } + } + + private static FieldDoc GetScoreDocAfter(LuceneQueryOptions luceneQueryOptions) + { + FieldDoc scoreDocAfter; + var searchAfter = luceneQueryOptions.SearchAfter; + + object[] searchAfterSortFields = new object[0]; + if (luceneQueryOptions.SearchAfter.Fields != null && luceneQueryOptions.SearchAfter.Fields.Length > 0) + { + searchAfterSortFields = luceneQueryOptions.SearchAfter.Fields; + } + if (searchAfter.ShardIndex != null) + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields, searchAfter.ShardIndex.Value); + } + else + { + scoreDocAfter = new FieldDoc(searchAfter.DocumentId, searchAfter.DocumentScore, searchAfterSortFields); + } + + return scoreDocAfter; + } + + private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) + { + SearchAfterOptions searchAfterOptions = null; + if (topDocs.TotalHits > 0) + { + FieldDoc lastFieldDoc = topDocs.ScoreDocs.LastOrDefault() as FieldDoc; if (lastFieldDoc != null) { searchAfterOptions = new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); } - return new LuceneSearchResults(results, totalItemCount, searchAfterOptions); } + + return searchAfterOptions; } private LuceneSearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) diff --git a/src/Examine.Lucene/Search/LuceneSearchResults.cs b/src/Examine.Lucene/Search/LuceneSearchResults.cs index efe700608..6ddc01a44 100644 --- a/src/Examine.Lucene/Search/LuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/LuceneSearchResults.cs @@ -6,19 +6,26 @@ namespace Examine.Lucene.Search { public class LuceneSearchResults : ILuceneSearchResults { - public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0, default); + public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0,float.NaN, default); private readonly IReadOnlyCollection _results; - public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount, SearchAfterOptions searchAfterOptions) + public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount,float maxScore, SearchAfterOptions searchAfterOptions) { _results = results; TotalItemCount = totalItemCount; + MaxScore = maxScore; SearchAfter = searchAfterOptions; } public long TotalItemCount { get; } + /// + /// Returns the maximum score value encountered. Note that in case + /// scores are not tracked, this returns . + /// + public float MaxScore { get; } + public SearchAfterOptions SearchAfter { get; } public IEnumerator GetEnumerator() => _results.GetEnumerator(); diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index 48d25b47a..f43cfc300 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -56,8 +56,8 @@ public void Allow_Leading_Wildcards() }).NativeQuery("*dney")); var results1 = query1.Execute(); - - Assert.AreEqual(2, results1.TotalItemCount); + + Assert.AreEqual(2, results1.TotalItemCount); } } @@ -1949,7 +1949,7 @@ public void Execute_With_Take_Max_Results() { for (int i = 0; i < 1000; i++) { - indexer.IndexItems(new[] {ValueSet.FromObject(i.ToString(), "content", new { Content = "hello world" })}); + indexer.IndexItems(new[] { ValueSet.FromObject(i.ToString(), "content", new { Content = "hello world" }) }); } indexer.IndexItems(new[] { ValueSet.FromObject(2000.ToString(), "content", new { Content = "donotfind" }) }); @@ -2490,7 +2490,7 @@ public void Paging_With_Skip_Take() var results = sc .Execute(QueryOptions.SkipTake(pageIndex * pageSize, pageSize)) - .ToList(); + .ToList(); Assert.AreEqual(2, results.Count); pageIndex++; @@ -2581,7 +2581,7 @@ public void SearchAfter_Results_Returns_Different_Results() //Arrange var sc = searcher.CreateQuery("content") .Field("writerName", "administrator") - .OrderByDescending(new SortableField("id",SortType.Int)); + .OrderByDescending(new SortableField("id", SortType.Int)); var luceneOptions = new LuceneQueryOptions(0, 2); //Act @@ -2590,14 +2590,17 @@ public void SearchAfter_Results_Returns_Different_Results() var results = sc.Execute(luceneOptions); var luceneResults = results as ILuceneSearchResults; Assert.IsNotNull(luceneResults); - Assert.IsNotNull(luceneResults.SearchAfter,"Search After details should be available"); + Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); var luceneResults1List = luceneResults.ToList(); Assert.IsTrue(luceneResults1List.Any(x => x.Id == "1")); Assert.IsTrue(luceneResults1List.Any(x => x.Id == "2")); // Second query result continues after result 1 (zero indexed), Takes 1, should not include any of the results before or include the SearchAfter docid / scoreid - var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, luceneResults.SearchAfter.DocumentScore, luceneResults.SearchAfter.Fields, luceneResults.SearchAfter.ShardIndex.Value); - var luceneOptions2 = new LuceneQueryOptions(1,1, searchAfter); + var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, + luceneResults.SearchAfter.DocumentScore, + luceneResults.SearchAfter.Fields, + luceneResults.SearchAfter.ShardIndex.Value); + var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); var results2 = sc.Execute(luceneOptions2); var luceneResults2 = results2 as ILuceneSearchResults; var luceneResults2List = luceneResults2.ToList(); @@ -2608,7 +2611,7 @@ public void SearchAfter_Results_Returns_Different_Results() // Third query result continues after result 2 (zero indexed), Takes 1 var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); - var luceneOptions3 = new LuceneQueryOptions(2, 1, searchAfter2); + var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); var results3 = sc.Execute(luceneOptions3); var luceneResults3 = results3 as ILuceneSearchResults; Assert.IsNotNull(luceneResults3); From 600b6eb31658a283f0476cc52d6db675b9e26d6f Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 00:35:49 +1300 Subject: [PATCH 06/16] fix merge facets and searchafter. --- .../Search/LuceneSearchExecutor.cs | 32 ++++++++++++------- .../Search/LuceneSearchResults.cs | 4 +-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index a296eac3c..25bd84b30 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -123,14 +123,26 @@ public ISearchResults Execute() { topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); } + FacetsCollector facetsCollector = null; + if (_facetFields.Count > 0) + { + facetsCollector = new FacetsCollector(); + } if (scoreDocAfter != null) { - topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, true, trackMaxScore); + if (facetsCollector != null) + { + topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); + } + else + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, true, trackMaxScore); + } } else { - searcher.IndexSearcher.Search(_luceneQuery, topDocsCollector); + searcher.IndexSearcher.Search(_luceneQuery, MultiCollector.Wrap(topDocsCollector, facetsCollector)); if (sortFields.Length > 0) { topDocs = ((TopFieldCollector)topDocsCollector).GetTopDocs(_options.Skip, _options.Take); @@ -151,8 +163,9 @@ public ISearchResults Execute() } var searchAfterOptions = GetSearchAfterOptions(topDocs); float maxScore = topDocs.MaxScore; + var facets = ExtractFacets(facetsCollector, searcher); - return new LuceneSearchResults(results, totalItemCount, maxScore, searchAfterOptions); + return new LuceneSearchResults(results, totalItemCount, maxScore, searchAfterOptions, facets); } } @@ -188,10 +201,8 @@ private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) { searchAfterOptions = new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); } - var facets = ExtractFacets(facetsCollector, searcher); - - return new LuceneSearchResults(results, totalItemCount, facets); } + return searchAfterOptions; } private IDictionary ExtractFacets(FacetsCollector facetsCollector, ISearcherReference searcher) @@ -206,7 +217,7 @@ private IDictionary ExtractFacets(FacetsCollector facetsCo SortedSetDocValuesReaderState sortedSetReaderState = null; - foreach(var field in facetFields) + foreach (var field in facetFields) { if (field is FacetFullTextField facetFullTextField) { @@ -218,7 +229,7 @@ private IDictionary ExtractFacets(FacetsCollector facetsCo var longFacets = longFacetCounts.GetTopChildren(0, facetLongField.Field); - if(longFacets == null) + if (longFacets == null) { continue; } @@ -239,7 +250,7 @@ private IDictionary ExtractFacets(FacetsCollector facetsCo var doubleFacets = doubleFacetCounts.GetTopChildren(0, facetDoubleField.Field); - if(doubleFacets == null) + if (doubleFacets == null) { continue; } @@ -274,7 +285,7 @@ private static void ExtractFullTextFacets(FacetsCollector facetsCollector, ISear { var sortedFacets = sortedFacetsCounts.GetTopChildren(facetFullTextField.MaxCount, facetFullTextField.Field); - if(sortedFacets == null) + if (sortedFacets == null) { return; } @@ -282,7 +293,6 @@ private static void ExtractFullTextFacets(FacetsCollector facetsCollector, ISear facets.Add(facetFullTextField.Field, new Examine.Search.FacetResult(sortedFacets.LabelValues.Select(labelValue => new FacetValue(labelValue.Label, labelValue.Value)))); } - return searchAfterOptions; } private LuceneSearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) diff --git a/src/Examine.Lucene/Search/LuceneSearchResults.cs b/src/Examine.Lucene/Search/LuceneSearchResults.cs index 41b74baa3..4c5f63cba 100644 --- a/src/Examine.Lucene/Search/LuceneSearchResults.cs +++ b/src/Examine.Lucene/Search/LuceneSearchResults.cs @@ -6,11 +6,11 @@ namespace Examine.Lucene.Search { public class LuceneSearchResults : ILuceneSearchResults, IFacetResults { - public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0,float.NaN, default, new Dictionary()); + public static LuceneSearchResults Empty { get; } = new LuceneSearchResults(Array.Empty(), 0, float.NaN, default, new Dictionary()); private readonly IReadOnlyCollection _results; - public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount,float maxScore, SearchAfterOptions searchAfterOptions, IDictionary facets) + public LuceneSearchResults(IReadOnlyCollection results, int totalItemCount, float maxScore, SearchAfterOptions searchAfterOptions, IDictionary facets) { _results = results; TotalItemCount = totalItemCount; From ed631209f2f392b6e20e0ff7511a4647e589f9fb Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 00:54:56 +1300 Subject: [PATCH 07/16] Wip test facet searchafter --- .../Search/LuceneSearchExecutor.cs | 10 ++-- .../Search/FacetFluentApiTests.cs | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 25bd84b30..f48d8cc5b 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -193,16 +193,20 @@ private static FieldDoc GetScoreDocAfter(LuceneQueryOptions luceneQueryOptions) private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) { - SearchAfterOptions searchAfterOptions = null; if (topDocs.TotalHits > 0) { FieldDoc lastFieldDoc = topDocs.ScoreDocs.LastOrDefault() as FieldDoc; if (lastFieldDoc != null) { - searchAfterOptions = new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + return new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + } + ScoreDoc scoreDoc = topDocs.ScoreDocs.LastOrDefault() as ScoreDoc; + if (scoreDoc != null) + { + return new SearchAfterOptions(scoreDoc.Doc, scoreDoc.Score, new object[0], scoreDoc.ShardIndex); } } - return searchAfterOptions; + return null; } private IDictionary ExtractFacets(FacetsCollector facetsCollector, ISearcherReference searcher) diff --git a/src/Examine.Test/Examine.Lucene/Search/FacetFluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FacetFluentApiTests.cs index 265b22d5e..df648b7d7 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FacetFluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FacetFluentApiTests.cs @@ -2674,5 +2674,55 @@ public void Given_SkipTake_Returns_ExpectedTotals_Facet(int skip, int take, int Assert.AreEqual(5, facetResults.Facet("umbraco").Value); } } + + + [TestCase(1, 2, 1, 2)] + [TestCase(2, 2, 2, 2)] + public void GivenSearchAfterTake_Returns_ExpectedTotals_Facet(int firstTake, int secondTake, int expectedFirstResultCount, int expectedSecondResultCount) + { + const int indexSize = 5; + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer, new FieldDefinitionCollection(new FieldDefinition("nodeName", FieldDefinitionTypes.FacetFullText)))) + { + var items = Enumerable.Range(0, indexSize).Select(x => ValueSet.FromObject(x.ToString(), "content", + new { nodeName = "umbraco", headerText = "world", writerName = "administrator" })); + + indexer.IndexItems(items); + + var searcher = indexer.Searcher; + + //Arrange + + var sc = searcher.CreateQuery("content").Field("writerName", "administrator").And().Facet("nodeName"); + + //Act + + var results1 = sc.Execute(new LuceneQueryOptions(0, firstTake)); + + var facetResults1 = results1.GetFacet("nodeName"); + + Assert.AreEqual(indexSize, results1.TotalItemCount); + Assert.AreEqual(expectedFirstResultCount, results1.Count()); + Assert.AreEqual(1, facetResults1.Count()); + Assert.AreEqual(5, facetResults1.Facet("umbraco").Value); + + var lucenceSearchResults1 = results1 as ILuceneSearchResults; + Assert.IsNotNull(lucenceSearchResults1); + + var results2 = sc.Execute(new LuceneQueryOptions(0, secondTake, lucenceSearchResults1.SearchAfter)); + + var facetResults2 = results2.GetFacet("nodeName"); + + Assert.AreEqual(indexSize, results2.TotalItemCount); + Assert.AreEqual(expectedSecondResultCount, results2.Count()); + Assert.AreEqual(1, facetResults2.Count()); + Assert.AreEqual(5, facetResults2.Facet("umbraco").Value); + var firstResults = results1.ToArray(); + var secondResults = results2.ToArray(); + Assert.IsFalse(firstResults.Any(x => secondResults.Any(y => y.Id == x.Id)), "The second set of results should not contain the first set of results"); + + } + } } } From 1b3e8bf69f5dfe3014ca98632d40620345ce63fb Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 01:00:23 +1300 Subject: [PATCH 08/16] Support non sorted query. --- .../Search/LuceneSearchExecutor.cs | 21 +++--- .../Examine.Lucene/Search/FluentApiTests.cs | 69 ++++++++++++++++++- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index b2f84ef4e..374afa856 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -118,9 +118,13 @@ public ISearchResults Execute() topDocsCollector = TopScoreDocCollector.Create(numHits, scoreDocAfter, true); } - if (scoreDocAfter != null) + if (scoreDocAfter != null && sort != null) { - topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, true, trackMaxScore); + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, trackDocScores, trackMaxScore); + } + else if (scoreDocAfter != null && sort == null) + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take); } else { @@ -174,17 +178,18 @@ private static FieldDoc GetScoreDocAfter(LuceneQueryOptions luceneQueryOptions) private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) { - SearchAfterOptions searchAfterOptions = null; if (topDocs.TotalHits > 0) { - FieldDoc lastFieldDoc = topDocs.ScoreDocs.LastOrDefault() as FieldDoc; - if (lastFieldDoc != null) + if (topDocs.ScoreDocs.LastOrDefault() is FieldDoc lastFieldDoc && lastFieldDoc != null) { - searchAfterOptions = new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + return new SearchAfterOptions(lastFieldDoc.Doc, lastFieldDoc.Score, lastFieldDoc.Fields?.ToArray(), lastFieldDoc.ShardIndex); + } + if (topDocs.ScoreDocs.LastOrDefault() is ScoreDoc scoreDoc && scoreDoc != null) + { + return new SearchAfterOptions(scoreDoc.Doc, scoreDoc.Score, new object[0], scoreDoc.ShardIndex); } } - - return searchAfterOptions; + return null; } private LuceneSearchResult GetSearchResult(int index, TopDocs topDocs, IndexSearcher luceneSearcher) diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index f43cfc300..6223b8b7d 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -2557,7 +2557,7 @@ public void Given_SkipTake_Returns_ExpectedTotals(int skip, int take, int expect } [Test] - public void SearchAfter_Results_Returns_Different_Results() + public void SearchAfter_Sorted_Results_Returns_Different_Results() { var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); using (var luceneDir = new RandomIdRAMDirectory()) @@ -2624,6 +2624,73 @@ public void SearchAfter_Results_Returns_Different_Results() } } + [Test] + public void SearchAfter_NonSorted_Results_Returns_Different_Results() + { + var analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion); + using (var luceneDir = new RandomIdRAMDirectory()) + using (var indexer = GetTestIndex(luceneDir, analyzer)) + { + indexer.IndexItems(new[] { + ValueSet.FromObject(1.ToString(), "content", + new { nodeName = "umbraco", headerText = "world", writerName = "administrator" }), + ValueSet.FromObject(2.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(3.ToString(), "content", + new { nodeName = "umbraco", headerText = "umbraco", writerName = "administrator" }), + ValueSet.FromObject(4.ToString(), "content", + new { nodeName = "umbraco", headerText = "nz", writerName = "administrator" }), + ValueSet.FromObject(5.ToString(), "content", + new { nodeName = "hello", headerText = "world", writerName = "blah" }) + }); + + var searcher = indexer.Searcher; + + //Arrange + var sc = searcher.CreateQuery("content") + .Field("writerName", "administrator"); + var luceneOptions = new LuceneQueryOptions(0, 2); + //Act + + //There are 4 results + // First query skips 0 and takes 2. + var results = sc.Execute(luceneOptions); + var luceneResults = results as ILuceneSearchResults; + Assert.IsNotNull(luceneResults); + Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); + var luceneResults1List = luceneResults.ToList(); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "1")); + Assert.IsTrue(luceneResults1List.Any(x => x.Id == "2")); + + // Second query result continues after result 1 (zero indexed), Takes 1, should not include any of the results before or include the SearchAfter docid / scoreid + var searchAfter = new SearchAfterOptions(luceneResults.SearchAfter.DocumentId, + luceneResults.SearchAfter.DocumentScore, + luceneResults.SearchAfter.Fields, + luceneResults.SearchAfter.ShardIndex.Value); + var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); + var results2 = sc.Execute(luceneOptions2); + var luceneResults2 = results2 as ILuceneSearchResults; + var luceneResults2List = luceneResults2.ToList(); + Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); + Assert.IsNotNull(luceneResults2); + + Assert.IsFalse(luceneResults2List.Any(x => luceneResults.ToList().Any(y => y.Id == x.Id)), "Results should not overlap"); + + // Third query result continues after result 2 (zero indexed), Takes 1 + var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); + var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); + var results3 = sc.Execute(luceneOptions3); + var luceneResults3 = results3 as ILuceneSearchResults; + Assert.IsNotNull(luceneResults3); + var luceneResults3List = luceneResults3.ToList(); + Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); + Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); + + Assert.AreNotEqual(results.First().Id, results2.First().Id, "Results should be different"); + } + } + #if NET6_0_OR_GREATER [Test] public void Range_DateOnly() From fb2932fdf07176cf64438dad5d79f962050b2cb1 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 01:11:17 +1300 Subject: [PATCH 09/16] merge --- .../Search/LuceneSearchExecutor.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 2b5cd85f0..8ad40139b 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -135,15 +135,21 @@ public ISearchResults Execute() { topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); } - topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, trackDocScores, trackMaxScore); + else + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, filter, _options.Take, sort, trackDocScores, trackMaxScore); + } } else if (scoreDocAfter != null && sort == null) { if (facetsCollector != null) { - topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); + topDocs = FacetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, filter, _options.Take, sort, MultiCollector.Wrap(topDocsCollector, facetsCollector)); + } + else + { + topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take); } - topDocs = searcher.IndexSearcher.SearchAfter(scoreDocAfter, _luceneQuery, _options.Take); } else { @@ -208,11 +214,6 @@ private static SearchAfterOptions GetSearchAfterOptions(TopDocs topDocs) { return new SearchAfterOptions(scoreDoc.Doc, scoreDoc.Score, new object[0], scoreDoc.ShardIndex); } - ScoreDoc scoreDoc = topDocs.ScoreDocs.LastOrDefault() as ScoreDoc; - if (scoreDoc != null) - { - return new SearchAfterOptions(scoreDoc.Doc, scoreDoc.Score, new object[0], scoreDoc.ShardIndex); - } } return null; } From 56ddb0fa403c82019b0a8cbd1c39866b5966426d Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 01:14:30 +1300 Subject: [PATCH 10/16] reorder --- src/Examine.Lucene/Search/LuceneSearchExecutor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 8ad40139b..21809cb24 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -133,7 +133,7 @@ public ISearchResults Execute() { if (facetsCollector != null) { - topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); + topDocs = FacetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, filter, _options.Take, sort, MultiCollector.Wrap(topDocsCollector, facetsCollector)); } else { @@ -144,7 +144,7 @@ public ISearchResults Execute() { if (facetsCollector != null) { - topDocs = FacetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, filter, _options.Take, sort, MultiCollector.Wrap(topDocsCollector, facetsCollector)); + topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); } else { From 4bb5ae6ba000579b15a5a2b11bb4476a0ab86f02 Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Tue, 13 Dec 2022 01:19:00 +1300 Subject: [PATCH 11/16] Collect facets --- src/Examine.Lucene/Search/LuceneSearchExecutor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs index 21809cb24..aad5a56f2 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExecutor.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExecutor.cs @@ -144,7 +144,7 @@ public ISearchResults Execute() { if (facetsCollector != null) { - topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, topDocsCollector); + topDocs = facetsCollector.SearchAfter(searcher.IndexSearcher, scoreDocAfter, _luceneQuery, _options.Take, MultiCollector.Wrap(topDocsCollector, facetsCollector)); } else { From f91fda2a3106c6199bb01f336ef8a0252dd2e83c Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Thu, 22 Dec 2022 15:01:31 +1300 Subject: [PATCH 12/16] remove comment --- docs/sorting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sorting.md b/docs/sorting.md index b34e11514..dfe5681e4 100644 --- a/docs/sorting.md +++ b/docs/sorting.md @@ -101,7 +101,7 @@ When using Lucene.NET as the Examine provider it is possible to more efficiently Steps: 1. Build and execute your query as normal. 2. Cast the ISearchResults from IQueryExecutor.Execute to ILuceneSearchResults -3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. It may be worth serializing this class and cryptographically hashing it to prevent tampering in a web application so that it can be made available to the next request for the next page. +3. Store ILuceneSearchResults.SearchAfter (SearchAfterOptions) for the next page. 4. Create the same query as the previous request. 5. When calling IQueryExecutor.Execute. Pass in new LuceneQueryOptions(skip,take, SearchAfterOptions); Skip will be ignored, the next take documents will be retrieved after the SearchAfterOptions document. 6. Repeat Steps 2-5 for each page. From 56ab3798e2e2542f8a1db90fd2e5e02618c7ca7c Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Thu, 22 Dec 2022 15:03:31 +1300 Subject: [PATCH 13/16] remove unused code --- src/Examine.Core/EmptySearchResults.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Examine.Core/EmptySearchResults.cs b/src/Examine.Core/EmptySearchResults.cs index bbaea7358..84c68e399 100644 --- a/src/Examine.Core/EmptySearchResults.cs +++ b/src/Examine.Core/EmptySearchResults.cs @@ -24,7 +24,6 @@ IEnumerator IEnumerable.GetEnumerator() public long TotalItemCount => 0; - public string ContinueWith => default; public IEnumerable Skip(int skip) { From 126e2653ce8b814b1e235fa5ba396243f436773a Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Thu, 22 Dec 2022 15:37:00 +1300 Subject: [PATCH 14/16] Add xdoc --- src/Examine.Lucene/Search/LuceneQueryOptions.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Examine.Lucene/Search/LuceneQueryOptions.cs b/src/Examine.Lucene/Search/LuceneQueryOptions.cs index e28595604..96ee34d97 100644 --- a/src/Examine.Lucene/Search/LuceneQueryOptions.cs +++ b/src/Examine.Lucene/Search/LuceneQueryOptions.cs @@ -13,6 +13,8 @@ public class LuceneQueryOptions : QueryOptions /// Number of result documents to skip. /// Optional number of result documents to take. /// Optionally skip to results after the results from the previous search execution. Used for efficent deep paging. + /// Whether to track the maximum document score. For best performance, if not needed, leave false. + /// Whether to Track Document Scores. For best performance, if not needed, leave false. public LuceneQueryOptions(int skip, int? take = null, SearchAfterOptions searchAfter = null, bool trackDocumentScores = false, bool trackDocumentMaxScore = false) : base(skip, take) { From 8ac1543b8b59184c678eccf9a5e54272672e485e Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Thu, 22 Dec 2022 15:56:41 +1300 Subject: [PATCH 15/16] Add ExecuteWithLucene --- .../Search/LuceneSearchExtensions.cs | 18 ++++++++++++++- .../Examine.Lucene/Search/FluentApiTests.cs | 22 +++++++------------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs index 37decee14..0661975a5 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using Examine.Search; using Lucene.Net.Search; @@ -50,5 +50,21 @@ public static BooleanOperation ToBooleanOperation(this Occur o) return BooleanOperation.Or; } } + /// + /// Executes the query + /// + public static ILuceneSearchResults ExecuteWithLucene(this IQueryExecutor queryExecutor, QueryOptions options = null) + { + if(queryExecutor is LuceneBooleanOperation + || queryExecutor is LuceneSearchQuery) + { + var results = queryExecutor.Execute(options); + if(results is ILuceneSearchResults luceneSearchResults) + { + return luceneSearchResults; + } + } + throw new NotSupportedException("QueryExecutor is not Lucene.NET"); + } } } diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index 6223b8b7d..286167b25 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -2587,8 +2587,7 @@ public void SearchAfter_Sorted_Results_Returns_Different_Results() //There are 4 results // First query skips 0 and takes 2. - var results = sc.Execute(luceneOptions); - var luceneResults = results as ILuceneSearchResults; + var luceneResults = sc.ExecuteWithLucene(luceneOptions); Assert.IsNotNull(luceneResults); Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); var luceneResults1List = luceneResults.ToList(); @@ -2601,8 +2600,7 @@ public void SearchAfter_Sorted_Results_Returns_Different_Results() luceneResults.SearchAfter.Fields, luceneResults.SearchAfter.ShardIndex.Value); var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); - var results2 = sc.Execute(luceneOptions2); - var luceneResults2 = results2 as ILuceneSearchResults; + var luceneResults2 = sc.ExecuteWithLucene(luceneOptions2); var luceneResults2List = luceneResults2.ToList(); Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); Assert.IsNotNull(luceneResults2); @@ -2612,15 +2610,14 @@ public void SearchAfter_Sorted_Results_Returns_Different_Results() // Third query result continues after result 2 (zero indexed), Takes 1 var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); - var results3 = sc.Execute(luceneOptions3); - var luceneResults3 = results3 as ILuceneSearchResults; + var luceneResults3 = sc.ExecuteWithLucene(luceneOptions3); Assert.IsNotNull(luceneResults3); var luceneResults3List = luceneResults3.ToList(); Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); - Assert.AreNotEqual(results.First().Id, results2.First().Id, "Results should be different"); + Assert.AreNotEqual(luceneResults.First().Id, luceneResults2.First().Id, "Results should be different"); } } @@ -2654,8 +2651,7 @@ public void SearchAfter_NonSorted_Results_Returns_Different_Results() //There are 4 results // First query skips 0 and takes 2. - var results = sc.Execute(luceneOptions); - var luceneResults = results as ILuceneSearchResults; + var luceneResults = sc.ExecuteWithLucene(luceneOptions); Assert.IsNotNull(luceneResults); Assert.IsNotNull(luceneResults.SearchAfter, "Search After details should be available"); var luceneResults1List = luceneResults.ToList(); @@ -2668,8 +2664,7 @@ public void SearchAfter_NonSorted_Results_Returns_Different_Results() luceneResults.SearchAfter.Fields, luceneResults.SearchAfter.ShardIndex.Value); var luceneOptions2 = new LuceneQueryOptions(0, 1, searchAfter); - var results2 = sc.Execute(luceneOptions2); - var luceneResults2 = results2 as ILuceneSearchResults; + var luceneResults2 = sc.ExecuteWithLucene(luceneOptions2); var luceneResults2List = luceneResults2.ToList(); Assert.IsTrue(luceneResults2List.Any(x => x.Id == "3"), $"Expected to contain next result after docId {luceneResults.SearchAfter.DocumentId}"); Assert.IsNotNull(luceneResults2); @@ -2679,15 +2674,14 @@ public void SearchAfter_NonSorted_Results_Returns_Different_Results() // Third query result continues after result 2 (zero indexed), Takes 1 var searchAfter2 = new SearchAfterOptions(luceneResults2.SearchAfter.DocumentId, luceneResults2.SearchAfter.DocumentScore, luceneResults2.SearchAfter.Fields, luceneResults2.SearchAfter.ShardIndex.Value); var luceneOptions3 = new LuceneQueryOptions(0, 1, searchAfter2); - var results3 = sc.Execute(luceneOptions3); - var luceneResults3 = results3 as ILuceneSearchResults; + var luceneResults3 = sc.ExecuteWithLucene(luceneOptions3); Assert.IsNotNull(luceneResults3); var luceneResults3List = luceneResults3.ToList(); Assert.IsTrue(luceneResults3List.Any(x => x.Id == "4"), $"Expected to contain next result after docId {luceneResults2.SearchAfter.DocumentId}"); Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults2.Any(y => y.Id == x.Id)), "Results should not overlap"); Assert.IsFalse(luceneResults3.ToList().Any(x => luceneResults.Any(y => y.Id == x.Id)), "Results should not overlap"); - Assert.AreNotEqual(results.First().Id, results2.First().Id, "Results should be different"); + Assert.AreNotEqual(luceneResults.First().Id, luceneResults2.First().Id, "Results should be different"); } } From 018aaf9a03a44a127d71d9ab7ed342e2d3570d2b Mon Sep 17 00:00:00 2001 From: Chad Currie Date: Thu, 22 Dec 2022 22:36:49 +1300 Subject: [PATCH 16/16] Support any IQueryExecutor that supports ILuceneSearchResults. --- src/Examine.Lucene/Search/LuceneSearchExtensions.cs | 10 +++------- .../Examine.Lucene/Search/FluentApiTests.cs | 7 +++---- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs index 0661975a5..b3028fbf9 100644 --- a/src/Examine.Lucene/Search/LuceneSearchExtensions.cs +++ b/src/Examine.Lucene/Search/LuceneSearchExtensions.cs @@ -55,14 +55,10 @@ public static BooleanOperation ToBooleanOperation(this Occur o) /// public static ILuceneSearchResults ExecuteWithLucene(this IQueryExecutor queryExecutor, QueryOptions options = null) { - if(queryExecutor is LuceneBooleanOperation - || queryExecutor is LuceneSearchQuery) + var results = queryExecutor.Execute(options); + if (results is ILuceneSearchResults luceneSearchResults) { - var results = queryExecutor.Execute(options); - if(results is ILuceneSearchResults luceneSearchResults) - { - return luceneSearchResults; - } + return luceneSearchResults; } throw new NotSupportedException("QueryExecutor is not Lucene.NET"); } diff --git a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs index 7ca9d4e5c..a3fa2072e 100644 --- a/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs +++ b/src/Examine.Test/Examine.Lucene/Search/FluentApiTests.cs @@ -4237,7 +4237,7 @@ public void GivenSearchAfterTake_Returns_ExpectedTotals_Facet(int firstTake, int //Act - var results1 = sc.Execute(new LuceneQueryOptions(0, firstTake)); + var results1 = sc.ExecuteWithLucene(new LuceneQueryOptions(0, firstTake)); var facetResults1 = results1.GetFacet("nodeName"); @@ -4246,10 +4246,9 @@ public void GivenSearchAfterTake_Returns_ExpectedTotals_Facet(int firstTake, int Assert.AreEqual(1, facetResults1.Count()); Assert.AreEqual(5, facetResults1.Facet("umbraco").Value); - var lucenceSearchResults1 = results1 as ILuceneSearchResults; - Assert.IsNotNull(lucenceSearchResults1); + Assert.IsNotNull(results1); - var results2 = sc.Execute(new LuceneQueryOptions(0, secondTake, lucenceSearchResults1.SearchAfter)); + var results2 = sc.Execute(new LuceneQueryOptions(0, secondTake, results1.SearchAfter)); var facetResults2 = results2.GetFacet("nodeName");