diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index cb697362..ddff43bd 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -234,6 +234,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 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 be576120..f1b485f3 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 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 { + document[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.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(document) + + 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 60d84972..be6dc51f 100644 --- a/Tests/MeiliSearchIntegrationTests/SearchTests.swift +++ b/Tests/MeiliSearchIntegrationTests/SearchTests.swift @@ -138,6 +138,98 @@ 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.1) + 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":[{"_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> + let query = "Moreninha" + + self.index.search(SearchParameters(query: query, showRankingScore: true)) { (result: MeiliResult) in + switch result { + case .success(let response): + do { + // 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") + } + 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":[{"comment":"A Book from Joaquim Manuel de Macedo","genres":["Novel"],"id":1844,"title":"A Moreninha"}],"processingTimeMs":0,"query":"Moreninha"} + """ + + 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 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") + } + 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 +558,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 +586,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 +619,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 +970,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