diff --git a/Sources/MeiliSearch/Indexes.swift b/Sources/MeiliSearch/Indexes.swift index 97e625dc..e42cdfa2 100755 --- a/Sources/MeiliSearch/Indexes.swift +++ b/Sources/MeiliSearch/Indexes.swift @@ -1028,6 +1028,46 @@ public struct Indexes { self.settings.resetPaginationSettings(self.uid, completion) } + // MARK: Typo Tolerance + + /** + Get the typo tolerance settings. + + - parameter completion: The completion closure is used to notify when the server + completes the query request, it returns a `Result` object that contains a `TypoToleranceResult` + value if the request was successful, or `Error` if a failure occurred. + */ + public func getTypoTolerance( + _ completion: @escaping (Result) -> Void) { + self.settings.getTypoTolerance(self.uid, completion) + } + + /** + Update the typo tolerance settings. + + - parameter typoTolerance: An object containing the settings for the `Index`. + - parameter completion: The completion closure is used to notify when the server + completes the query request, it returns a `Result` object that contains `TaskInfo` + value if the request was successful, or `Error` if a failure occurred. + */ + public func updateTypoTolerance( + _ typoTolerance: TypoTolerance, + _ completion: @escaping (Result) -> Void) { + self.settings.updateTypoTolerance(self.uid, typoTolerance, completion) + } + + /** + Reset the typo tolerance settings. + + - parameter completion: The completion closure is used to notify when the server + completes the query request, it returns a `Result` object that contains `TaskInfo` + value if the request was successful, or `Error` if a failure occurred. + */ + public func resetTypoTolerance( + _ completion: @escaping (Result) -> Void) { + self.settings.resetTypoTolerance(self.uid, completion) + } + // MARK: Stats /** diff --git a/Sources/MeiliSearch/Model/Setting.swift b/Sources/MeiliSearch/Model/Setting.swift index a5fe4b04..7d46daba 100644 --- a/Sources/MeiliSearch/Model/Setting.swift +++ b/Sources/MeiliSearch/Model/Setting.swift @@ -4,7 +4,7 @@ import Foundation #endif /** - Settings object provided byb the user + Settings object provided by the user */ public struct Setting: Codable, Equatable { // MARK: Properties @@ -33,6 +33,9 @@ public struct Setting: Codable, Equatable { /// List of attributes used for sorting public let sortableAttributes: [String]? + /// Settings for typo tolerance + public let typoTolerance: TypoTolerance? + /// List of tokens that will be considered as word separators by Meilisearch. public let separatorTokens: [String]? @@ -59,7 +62,8 @@ public struct Setting: Codable, Equatable { separatorTokens: [String]? = nil, nonSeparatorTokens: [String]? = nil, dictionary: [String]? = nil, - pagination: Pagination? = nil + pagination: Pagination? = nil, + typoTolerance: TypoTolerance? = nil ) { self.rankingRules = rankingRules self.searchableAttributes = searchableAttributes @@ -73,5 +77,6 @@ public struct Setting: Codable, Equatable { self.separatorTokens = separatorTokens self.dictionary = dictionary self.pagination = pagination + self.typoTolerance = typoTolerance } } diff --git a/Sources/MeiliSearch/Model/SettingResult.swift b/Sources/MeiliSearch/Model/SettingResult.swift index 2ce7fcc1..ae5efecf 100644 --- a/Sources/MeiliSearch/Model/SettingResult.swift +++ b/Sources/MeiliSearch/Model/SettingResult.swift @@ -39,4 +39,7 @@ public struct SettingResult: Codable, Equatable { /// Pagination settings for the current index public let pagination: Pagination + + /// Settings for typo tolerance + public let typoTolerance: TypoToleranceResult } diff --git a/Sources/MeiliSearch/Model/Task.swift b/Sources/MeiliSearch/Model/Task.swift index 476c87d3..13a85a3b 100644 --- a/Sources/MeiliSearch/Model/Task.swift +++ b/Sources/MeiliSearch/Model/Task.swift @@ -88,6 +88,8 @@ public struct Task: Codable, Equatable { /// Settings for index level pagination rules public let pagination: Pagination? + /// Typo tolerance on settings actions + public let typoTolerance: TypoTolerance? } /// Error information in case of failed update. public let error: MeiliSearch.MSErrorResponse? diff --git a/Sources/MeiliSearch/Model/TypoTolerance.swift b/Sources/MeiliSearch/Model/TypoTolerance.swift new file mode 100644 index 00000000..c11b0ef4 --- /dev/null +++ b/Sources/MeiliSearch/Model/TypoTolerance.swift @@ -0,0 +1,48 @@ +import Foundation + +/** + `TypoTolerance` settings provided by the user. + */ +public struct TypoTolerance: Codable, Equatable { + // MARK: Properties + + /// Whether typo tolerance is enabled or not + public let enabled: Bool? + + /// The minimum word size for accepting typos + public let minWordSizeForTypos: MinWordSize? + + /// An array of words for which the typo tolerance feature is disabled + public let disableOnWords: [String]? + + /// An array of attributes for which the typo tolerance feature is disabled + public let disableOnAttributes: [String]? + + public struct MinWordSize: Codable, Equatable { + /// The minimum word size for accepting 1 typo; must be between 0 and `twoTypos` + public let oneTypo: Int? + + /// The minimum word size for accepting 2 typos; must be between `oneTypo` and 255 + public let twoTypos: Int? + + public init( + oneTypo: Int? = nil, + twoTypos: Int? = nil + ) { + self.oneTypo = oneTypo + self.twoTypos = twoTypos + } + } + + public init( + enabled: Bool? = nil, + minWordSizeForTypos: TypoTolerance.MinWordSize? = nil, + disableOnWords: [String]? = nil, + disableOnAttributes: [String]? = nil + ) { + self.enabled = enabled + self.minWordSizeForTypos = minWordSizeForTypos + self.disableOnWords = disableOnWords + self.disableOnAttributes = disableOnAttributes + } +} diff --git a/Sources/MeiliSearch/Model/TypoToleranceResult.swift b/Sources/MeiliSearch/Model/TypoToleranceResult.swift new file mode 100644 index 00000000..0a849e73 --- /dev/null +++ b/Sources/MeiliSearch/Model/TypoToleranceResult.swift @@ -0,0 +1,28 @@ +import Foundation + +/** + `TypoToleranceResult` instances represent the current typo tolerance settings. + */ +public struct TypoToleranceResult: Codable, Equatable { + // MARK: Properties + + /// Whether typo tolerance is enabled or not + public let enabled: Bool + + /// The minimum word size for accepting typos + public let minWordSizeForTypos: MinWordSize + + /// An array of words for which the typo tolerance feature is disabled + public let disableOnWords: [String] + + /// An array of attributes for which the typo tolerance feature is disabled + public let disableOnAttributes: [String] + + public struct MinWordSize: Codable, Equatable { + /// The minimum word size for accepting 1 typo; must be between 0 and `twoTypos` + public let oneTypo: Int + + /// The minimum word size for accepting 2 typos; must be between `oneTypo` and 255 + public let twoTypos: Int + } +} diff --git a/Sources/MeiliSearch/Settings.swift b/Sources/MeiliSearch/Settings.swift index 92c1937c..58b4bfe4 100644 --- a/Sources/MeiliSearch/Settings.swift +++ b/Sources/MeiliSearch/Settings.swift @@ -372,6 +372,51 @@ struct Settings { resetSetting(uid: uid, key: "pagination", completion: completion) } + // MARK: Typo Tolerance + + func getTypoTolerance( + _ uid: String, + _ completion: @escaping (Result) -> Void) { + + getSetting(uid: uid, key: "typo-tolerance", completion: completion) + } + + func updateTypoTolerance( + _ uid: String, + _ setting: TypoTolerance, + _ completion: @escaping (Result) -> Void) { + + let data: Data + do { + data = try JSONEncoder().encode(setting) + } catch { + completion(.failure(error)) + return + } + + // this uses patch instead of put for networking, so shouldn't use the reusable 'updateSetting' function + self.request.patch(api: "/indexes/\(uid)/settings/typo-tolerance", data) { result in + switch result { + case .success(let data): + do { + let task: TaskInfo = try Constants.customJSONDecoder.decode(TaskInfo.self, from: data) + completion(.success(task)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func resetTypoTolerance( + _ uid: String, + _ completion: @escaping (Result) -> Void) { + + resetSetting(uid: uid, key: "typo-tolerance", completion: completion) + } + // MARK: Reusable Requests private func getSetting( diff --git a/Tests/MeiliSearchIntegrationTests/SettingsTests.swift b/Tests/MeiliSearchIntegrationTests/SettingsTests.swift index d28d51a0..6fda8e84 100644 --- a/Tests/MeiliSearchIntegrationTests/SettingsTests.swift +++ b/Tests/MeiliSearchIntegrationTests/SettingsTests.swift @@ -31,6 +31,18 @@ class SettingsTests: XCTestCase { private let defaultStopWords: [String] = [] private let defaultSynonyms: [String: [String]] = [:] private let defaultPagination: Pagination = .init(maxTotalHits: 1000) + private let defaultTypoTolerance: TypoTolerance = .init( + enabled: true, + minWordSizeForTypos: .init(oneTypo: 5, twoTypos: 9), + disableOnWords: [], + disableOnAttributes: [] + ) + private let defaultTypoToleranceResult: TypoToleranceResult = .init( + enabled: true, + minWordSizeForTypos: .init(oneTypo: 5, twoTypos: 9), + disableOnWords: [], + disableOnAttributes: [] + ) private var defaultGlobalSettings: Setting? private var defaultGlobalReturnedSettings: SettingResult? @@ -68,7 +80,8 @@ class SettingsTests: XCTestCase { separatorTokens: self.defaultSeparatorTokens, nonSeparatorTokens: self.defaultNonSeparatorTokens, dictionary: self.defaultDictionary, - pagination: self.defaultPagination + pagination: self.defaultPagination, + typoTolerance: self.defaultTypoTolerance ) self.defaultGlobalReturnedSettings = SettingResult( @@ -83,7 +96,8 @@ class SettingsTests: XCTestCase { separatorTokens: self.defaultSeparatorTokens, nonSeparatorTokens: self.defaultNonSeparatorTokens, dictionary: self.defaultDictionary, - pagination: self.defaultPagination + pagination: self.defaultPagination, + typoTolerance: self.defaultTypoToleranceResult ) } @@ -525,6 +539,100 @@ class SettingsTests: XCTestCase { self.wait(for: [expectation], timeout: TESTS_TIME_OUT) } + // MARK: Typo Tolerance + + func testGetTypoTolerance() { + let expectation = XCTestExpectation(description: "Get current typo tolerance") + + self.index.getTypoTolerance { result in + switch result { + case .success(let typoTolerance): + XCTAssertEqual(self.defaultTypoToleranceResult, typoTolerance) + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to get typo tolerance") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testUpdateTypoTolerance() { + let expectation = XCTestExpectation(description: "Update settings for typo tolerance") + + let newTypoTolerance: TypoTolerance = .init( + minWordSizeForTypos: .init( + oneTypo: 1, + twoTypos: 2 + ), + disableOnWords: ["to"], + disableOnAttributes: ["genre"] + ) + + self.index.updateTypoTolerance(newTypoTolerance) { result in + switch result { + case .success(let task): + self.client.waitForTask(task: task) { result in + switch result { + case .success(let task): + XCTAssertEqual("settingsUpdate", task.type) + XCTAssertEqual(Task.Status.succeeded, task.status) + if let details = task.details { + if let typoTolerance = details.typoTolerance { + XCTAssertEqual(newTypoTolerance, typoTolerance) + } else { + XCTFail("typoTolerance should not be nil") + } + } else { + XCTFail("details should exists in details field of task") + } + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to wait for task") + expectation.fulfill() + } + } + case .failure(let error): + dump(error) + XCTFail("Failed updating typo tolerance") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testResetTypoTolerance() { + let expectation = XCTestExpectation(description: "Reset settings for typo tolerance") + + self.index.resetTypoTolerance { result in + switch result { + case .success(let task): + self.client.waitForTask(task: task) { result in + switch result { + case .success(let task): + XCTAssertEqual("settingsUpdate", task.type) + XCTAssertEqual(Task.Status.succeeded, task.status) + expectation.fulfill() + case .failure(let error): + dump(error) + XCTFail("Failed to wait for task") + expectation.fulfill() + } + } + case .failure(let error): + dump(error) + XCTFail("Failed reseting typo tolerance") + expectation.fulfill() + } + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + // MARK: Separator Tokens func testGetSeparatorTokens() { @@ -1260,7 +1368,8 @@ class SettingsTests: XCTestCase { separatorTokens: [], nonSeparatorTokens: [], dictionary: [], - pagination: .init(maxTotalHits: 1000) + pagination: .init(maxTotalHits: 1000), + typoTolerance: defaultTypoToleranceResult ) let expectation = XCTestExpectation(description: "Update settings") @@ -1341,7 +1450,8 @@ class SettingsTests: XCTestCase { separatorTokens: ["&"], nonSeparatorTokens: ["#"], dictionary: ["J.K"], - pagination: .init(maxTotalHits: 500) + pagination: .init(maxTotalHits: 500), + typoTolerance: nil ) let expectedSettingResult = SettingResult( @@ -1356,7 +1466,8 @@ class SettingsTests: XCTestCase { separatorTokens: ["&"], nonSeparatorTokens: ["#"], dictionary: ["J.K"], - pagination: .init(maxTotalHits: 500) + pagination: .init(maxTotalHits: 500), + typoTolerance: defaultTypoToleranceResult ) self.index.updateSettings(newSettings) { result in diff --git a/Tests/MeiliSearchUnitTests/SettingsTests.swift b/Tests/MeiliSearchUnitTests/SettingsTests.swift index 5299818a..642000d9 100644 --- a/Tests/MeiliSearchUnitTests/SettingsTests.swift +++ b/Tests/MeiliSearchUnitTests/SettingsTests.swift @@ -39,6 +39,15 @@ class SettingsTests: XCTestCase { "synonyms": { "wolverine": ["xmen", "logan"], "logan": ["wolverine", "xmen"] + }, + "typoTolerance": { + "enabled": true, + "minWordSizeForTypos": { + "oneTypo": 5, + "twoTypos": 9 + }, + "disableOnWords": [], + "disableOnAttributes": [] } } """ @@ -853,6 +862,94 @@ class SettingsTests: XCTestCase { self.wait(for: [expectation], timeout: TESTS_TIME_OUT) } + // MARK: Typo Tolerance + + func testGetTypoTolerance() { + let jsonString = """ + {"enabled":true,"minWordSizeForTypos":{"oneTypo":3,"twoTypos":7},"disableOnWords":["of", "the"],"disableOnAttributes":["genre"]} + """ + + // Prepare the mock server + session.pushData(jsonString) + + // Start the test with the mocked server + let expectation = XCTestExpectation(description: "Get displayed attribute") + + self.index.getTypoTolerance { result in + switch result { + case .success(let typoTolerance): + XCTAssertTrue(typoTolerance.enabled) + XCTAssertEqual(typoTolerance.minWordSizeForTypos.oneTypo, 3) + XCTAssertEqual(typoTolerance.minWordSizeForTypos.twoTypos, 7) + XCTAssertFalse(typoTolerance.disableOnWords.isEmpty) + XCTAssertFalse(typoTolerance.disableOnAttributes.isEmpty) + case .failure: + XCTFail("Failed to get displayed attribute") + } + expectation.fulfill() + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testUpdateTypoTolerance() { + let jsonString = """ + {"taskUid":0,"indexUid":"movies_test","status":"enqueued","type":"settingsUpdate","enqueuedAt":"2022-07-27T19:03:50.494232841Z"} + """ + + // Prepare the mock server + let decoder = JSONDecoder() + let stubTask: TaskInfo = try! decoder.decode( + TaskInfo.self, + from: jsonString.data(using: .utf8)!) + + session.pushData(jsonString) + let typoTolerance: TypoTolerance = .init(enabled: false) + + // Start the test with the mocked server + let expectation = XCTestExpectation(description: "Update displayed attribute") + + self.index.updateTypoTolerance(typoTolerance) { result in + switch result { + case .success(let update): + XCTAssertEqual(stubTask, update) + case .failure: + XCTFail("Failed to update displayed attribute") + } + expectation.fulfill() + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + + func testResetTypoTolerance() { + let jsonString = """ + {"taskUid":0,"indexUid":"movies_test","status":"enqueued","type":"settingsUpdate","enqueuedAt":"2022-07-27T19:03:50.494232841Z"} + """ + + // Prepare the mock server + let decoder = JSONDecoder() + let stubTask: TaskInfo = try! decoder.decode( + TaskInfo.self, + from: jsonString.data(using: .utf8)!) + session.pushData(jsonString) + + // Start the test with the mocked server + let expectation = XCTestExpectation(description: "Update displayed attribute") + + self.index.resetTypoTolerance { result in + switch result { + case .success(let update): + XCTAssertEqual(stubTask, update) + case .failure: + XCTFail("Failed to update displayed attribute") + } + expectation.fulfill() + } + + self.wait(for: [expectation], timeout: TESTS_TIME_OUT) + } + private func buildStubSetting(from json: String) throws -> Setting { let data = Data(json.utf8) let decoder = JSONDecoder()