From 002d2619fa5e64fa0f1b6920194b2341d5ae86b8 Mon Sep 17 00:00:00 2001 From: James Sherlock <15193942+Sherlouk@users.noreply.github.com> Date: Sun, 17 Sep 2023 12:40:08 +0100 Subject: [PATCH 1/3] Add support for search ranking score --- .../MeiliSearch/Model/SearchParameters.swift | 8 +- Sources/MeiliSearch/Model/SearchResult.swift | 33 ++++++- .../SearchTests.swift | 94 ++++++++++++++++++- 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/Sources/MeiliSearch/Model/SearchParameters.swift b/Sources/MeiliSearch/Model/SearchParameters.swift index 975ff11c..2c569ddd 100644 --- a/Sources/MeiliSearch/Model/SearchParameters.swift +++ b/Sources/MeiliSearch/Model/SearchParameters.swift @@ -62,6 +62,9 @@ public struct SearchParameters: Codable, Equatable { /// Whether to return the raw matches or not. public let showMatchesPosition: Bool? + /// Whether to return the search ranking score or not. + public let showRankingScore: Bool? + // MARK: Initializers public init( @@ -81,7 +84,8 @@ public struct SearchParameters: Codable, Equatable { filter: String? = nil, sort: [String]? = nil, facets: [String]? = nil, - showMatchesPosition: Bool? = nil) { + showMatchesPosition: Bool? = nil, + showRankingScore: Bool? = nil) { self.query = query self.offset = offset self.limit = limit @@ -99,6 +103,7 @@ public struct SearchParameters: Codable, Equatable { self.sort = sort self.facets = facets self.showMatchesPosition = showMatchesPosition + self.showRankingScore = showRankingScore } // MARK: Query Initializers @@ -133,5 +138,6 @@ public struct SearchParameters: Codable, Equatable { case showMatchesPosition case hitsPerPage case page + case showRankingScore } } diff --git a/Sources/MeiliSearch/Model/SearchResult.swift b/Sources/MeiliSearch/Model/SearchResult.swift index c1d92124..ffff2f6f 100644 --- a/Sources/MeiliSearch/Model/SearchResult.swift +++ b/Sources/MeiliSearch/Model/SearchResult.swift @@ -11,7 +11,7 @@ public class Searchable: Equatable, Codable where T: Codable, T: Equatable { // MARK: Properties /// Possible hints from the search query. - public var hits: [T] = [] + public var hits: [SearchHit] = [] /// Distribution of the given facets. public var facetDistribution: [String: [String: Int]]? @@ -34,6 +34,37 @@ public class Searchable: Equatable, Codable where T: Codable, T: Equatable { } } +@dynamicMemberLookup +public struct SearchHit: Equatable, Codable where T: Codable, T: Equatable { + public let result: T + public let rankingScore: Double? + + /// Dynamic member lookup is used to allow easy access to instance members of the hit result, maintaining a level of backwards compatibility. + public subscript(dynamicMember keyPath: KeyPath) -> V { + result[keyPath: keyPath] + } + + // MARK: Codable + + enum CodingKeys: String, CodingKey { + case rankingScore = "_rankingScore" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.result = try T(from: decoder) + self.rankingScore = try container.decodeIfPresent(Double.self, forKey: .rankingScore) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(result) + + var containerTwo = encoder.container(keyedBy: CodingKeys.self) + try containerTwo.encodeIfPresent(rankingScore, forKey: .rankingScore) + } +} + /** `SearchResult` instances represent the result of a search. Requires that the value `T` conforms to the `Codable` and `Equatable` protocols. diff --git a/Tests/MeiliSearchIntegrationTests/SearchTests.swift b/Tests/MeiliSearchIntegrationTests/SearchTests.swift index 7cbe26e4..c2e8a43c 100644 --- a/Tests/MeiliSearchIntegrationTests/SearchTests.swift +++ b/Tests/MeiliSearchIntegrationTests/SearchTests.swift @@ -138,6 +138,90 @@ class SearchTests: XCTestCase { self.wait(for: [expectation], timeout: TESTS_TIME_OUT) } + // MARK: Search ranking + func testSearchRankingScore() { + let expectation = XCTestExpectation(description: "Search for Books with query") + + typealias MeiliResult = Result, Swift.Error> + let query = "Moreninha" + + self.index.search(SearchParameters(query: query, showRankingScore: true)) { (result: MeiliResult) in + switch result { + case .success(let response): + let result = response as! SearchResult + XCTAssertEqual(result.hits.count, 1) + XCTAssertGreaterThan(result.hits[0].rankingScore ?? 0, 0.9) + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to search with testSearchRankingScore") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testSearchBoxEncodingWithScore() { + let expectation = XCTestExpectation(description: "Search for Books with query") + + let expectedValue = """ + {"hits":[{"id":1844,"title":"A Moreninha","comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"],"_rankingScore":0.90404040404040409}],"query":"Moreninha","processingTimeMs":0} + """ + + typealias MeiliResult = Result, Swift.Error> + let query = "Moreninha" + + self.index.search(SearchParameters(query: query, showRankingScore: true)) { (result: MeiliResult) in + switch result { + case .success(let response): + do { + let data = try JSONEncoder().encode(response) + XCTAssertEqual(String(decoding: data, as: UTF8.self), expectedValue) + } catch { + XCTFail("Failed to encode search result") + } + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to search with testSearchBoxEncodingWithScore") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testSearchBoxEncodingWithoutScore() { + let expectation = XCTestExpectation(description: "Search for Books with query") + + let expectedValue = """ + {"hits":[{"id":1844,"title":"A Moreninha","comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"]}],"query":"Moreninha","processingTimeMs":0} + """ + + typealias MeiliResult = Result, Swift.Error> + let query = "Moreninha" + + self.index.search(SearchParameters(query: query, showRankingScore: false)) { (result: MeiliResult) in + switch result { + case .success(let response): + do { + let data = try JSONEncoder().encode(response) + XCTAssertEqual(String(decoding: data, as: UTF8.self), expectedValue) + } catch { + XCTFail("Failed to encode search result") + } + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to search with testSearchBoxEncodingWithoutScore") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + // MARK: Basic search with finite pagination func testBasicSearchWithFinitePagination() { let expectation = XCTestExpectation(description: "Search for Books with finite pagination") @@ -466,7 +550,7 @@ class SearchTests: XCTestCase { XCTAssertEqual(result.limit, limit) XCTAssertEqual(documents.hits.count, 1) - let book: Book = documents.hits[0] + let book: SearchHit = documents.hits[0] XCTAssertEqual("…from Joaquim Manuel de Macedo", book.formatted!.comment!) expectation.fulfill() case .failure(let error): @@ -494,7 +578,7 @@ class SearchTests: XCTestCase { self.index.search(searchParameters) { (result: MeiliResult) in switch result { case .success(let documents): - let book: Book = documents.hits[0] + let book: SearchHit = documents.hits[0] XCTAssertEqual("(ꈍᴗꈍ)Joaquim Manuel(ꈍᴗꈍ)", book.formatted!.comment!) expectation.fulfill() case .failure(let error): @@ -527,7 +611,7 @@ class SearchTests: XCTestCase { XCTAssertEqual(result.limit, limit) XCTAssertEqual(documents.hits.count, 2) - let moreninhaBook: Book = documents.hits.first(where: { book in book.id == 1844 })! + let moreninhaBook: SearchHit = documents.hits.first(where: { book in book.id == 1844 })! XCTAssertEqual("A Book from Joaquim Manuel…", moreninhaBook.formatted!.comment!) expectation.fulfill() case .failure(let error): @@ -878,7 +962,7 @@ class SearchTests: XCTestCase { XCTAssertEqual(documents.query, query) XCTAssertEqual(result.limit, limit) XCTAssertEqual(documents.hits.count, 1) - guard let book: Book = documents.hits.first(where: { book in book.id == 1344 }) else { + guard let book: SearchHit = documents.hits.first(where: { book in book.id == 1344 }) else { XCTFail("Failed to search with testSearchFilterWithEmptySpace") expectation.fulfill() return @@ -1018,7 +1102,7 @@ class SearchTests: XCTestCase { XCTAssertEqual(documents.query, query) XCTAssertEqual(result.limit, limit) XCTAssertEqual(documents.hits.count, limit) - + XCTAssertEqual(documents.facetStats?["id"]?.min, 1) XCTAssertEqual(documents.facetStats?["id"]?.max, 1844) From 117b3ad65b07a0169083769fced32e2e28070c08 Mon Sep 17 00:00:00 2001 From: James Sherlock <15193942+Sherlouk@users.noreply.github.com> Date: Sun, 17 Sep 2023 12:44:24 +0100 Subject: [PATCH 2/3] Update code samples with ranking score example --- .code-samples.meilisearch.yaml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index b5f6d3de..5dc3b3cb 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -169,7 +169,7 @@ async_guide_canceled_by: |- } } search_parameter_guide_hitsperpage_1: |- - let searchParameters = SearchParameters.query("", hitsPerPage: 15) + let searchParameters = SearchParameters(query: "", hitsPerPage: 15) client.index("movies").search(searchParameters) { (result: Result, Swift.Error>) in switch result { case .success(let searchResult): @@ -179,7 +179,7 @@ search_parameter_guide_hitsperpage_1: |- } } search_parameter_guide_page_1: |- - let searchParameters = SearchParameters.query("", page: 15) + let searchParameters = SearchParameters(query: "", page: 15) client.index("movies").search(searchParameters) { (result: Result, Swift.Error>) in switch result { case .success(let searchResult): @@ -198,6 +198,16 @@ search_parameter_guide_attributes_to_search_on_1: |- print(error) } } +search_parameter_guide_show_ranking_score_1: |- + let searchParameters = SearchParameters(query: "dragon", showRankingScore: true) + client.index("movies").search(searchParameters) { (result: Result, Swift.Error>) in + switch result { + case .success(let searchResult): + print(searchResult.rankingScore) + case .failure(let error): + print(error) + } + } authorization_header_1: |- client = try MeiliSearch(host: "http://localhost:7700", apiKey: "masterKey") client.getKeys { result in From d0a6a20e2c2d4a973deb1d915a2a7da23d3af4b1 Mon Sep 17 00:00:00 2001 From: James Sherlock <15193942+Sherlouk@users.noreply.github.com> Date: Thu, 21 Sep 2023 22:58:35 +0100 Subject: [PATCH 3/3] Patch Tests & Rename 'result' to 'document' --- Sources/MeiliSearch/Model/SearchResult.swift | 10 +++++----- .../SearchTests.swift | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/MeiliSearch/Model/SearchResult.swift b/Sources/MeiliSearch/Model/SearchResult.swift index ffff2f6f..912bbe30 100644 --- a/Sources/MeiliSearch/Model/SearchResult.swift +++ b/Sources/MeiliSearch/Model/SearchResult.swift @@ -36,12 +36,12 @@ public class Searchable: Equatable, Codable where T: Codable, T: Equatable { @dynamicMemberLookup public struct SearchHit: Equatable, Codable where T: Codable, T: Equatable { - public let result: T - public let rankingScore: Double? + public let document: T + public internal(set) var rankingScore: Double? /// Dynamic member lookup is used to allow easy access to instance members of the hit result, maintaining a level of backwards compatibility. public subscript(dynamicMember keyPath: KeyPath) -> V { - result[keyPath: keyPath] + document[keyPath: keyPath] } // MARK: Codable @@ -52,13 +52,13 @@ public struct SearchHit: Equatable, Codable where T: Codable, T: Equatable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.result = try T(from: decoder) + self.document = try T(from: decoder) self.rankingScore = try container.decodeIfPresent(Double.self, forKey: .rankingScore) } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(result) + try container.encode(document) var containerTwo = encoder.container(keyedBy: CodingKeys.self) try containerTwo.encodeIfPresent(rankingScore, forKey: .rankingScore) diff --git a/Tests/MeiliSearchIntegrationTests/SearchTests.swift b/Tests/MeiliSearchIntegrationTests/SearchTests.swift index c2e8a43c..be6dc51f 100644 --- a/Tests/MeiliSearchIntegrationTests/SearchTests.swift +++ b/Tests/MeiliSearchIntegrationTests/SearchTests.swift @@ -150,7 +150,7 @@ class SearchTests: XCTestCase { case .success(let response): let result = response as! SearchResult XCTAssertEqual(result.hits.count, 1) - XCTAssertGreaterThan(result.hits[0].rankingScore ?? 0, 0.9) + XCTAssertGreaterThan(result.hits[0].rankingScore ?? 0, 0.1) expectation.fulfill() case .failure(let error): dump(error) @@ -166,7 +166,7 @@ class SearchTests: XCTestCase { let expectation = XCTestExpectation(description: "Search for Books with query") let expectedValue = """ - {"hits":[{"id":1844,"title":"A Moreninha","comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"],"_rankingScore":0.90404040404040409}],"query":"Moreninha","processingTimeMs":0} + {"hits":[{"_rankingScore":0.5,"comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"],"id":1844,"title":"A Moreninha"}],"processingTimeMs":0,"query":"Moreninha"} """ typealias MeiliResult = Result, Swift.Error> @@ -176,7 +176,12 @@ class SearchTests: XCTestCase { switch result { case .success(let response): do { - let data = try JSONEncoder().encode(response) + // the ranking score and time can change for many reasons, of which is not relevant here. we set it to a constant to test the encoding. + response.processingTimeMs = 0 + response.hits[0].rankingScore = 0.5 + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(response) XCTAssertEqual(String(decoding: data, as: UTF8.self), expectedValue) } catch { XCTFail("Failed to encode search result") @@ -196,7 +201,7 @@ class SearchTests: XCTestCase { let expectation = XCTestExpectation(description: "Search for Books with query") let expectedValue = """ - {"hits":[{"id":1844,"title":"A Moreninha","comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"]}],"query":"Moreninha","processingTimeMs":0} + {"hits":[{"comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"],"id":1844,"title":"A Moreninha"}],"processingTimeMs":0,"query":"Moreninha"} """ typealias MeiliResult = Result, Swift.Error> @@ -206,7 +211,10 @@ class SearchTests: XCTestCase { switch result { case .success(let response): do { - let data = try JSONEncoder().encode(response) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + response.processingTimeMs = 0 + let data = try encoder.encode(response) XCTAssertEqual(String(decoding: data, as: UTF8.self), expectedValue) } catch { XCTFail("Failed to encode search result")