diff --git a/Source/AppleStorage/Alias/SetStorageProtocol.swift b/Source/AppleStorage/Alias/SetStorageProtocol.swift new file mode 100644 index 00000000..a7aa943d --- /dev/null +++ b/Source/AppleStorage/Alias/SetStorageProtocol.swift @@ -0,0 +1,11 @@ +// +// SetStorageProtocol.swift +// SabyAppleStorage +// + +import Foundation + +extension SetStorage { + public typealias SelfProtocol = SetStorage + public typealias AnyProtocol = any SetStorage +} diff --git a/Source/AppleStorage/Implement/CoreDataSetStorage.swift b/Source/AppleStorage/Implement/CoreDataSetStorage.swift new file mode 100644 index 00000000..900a5978 --- /dev/null +++ b/Source/AppleStorage/Implement/CoreDataSetStorage.swift @@ -0,0 +1,455 @@ +// +// CoreDataSetStorage.swift +// SabyAppleStorage +// + +import CoreData + +import SabyConcurrency +import SabyJSON +import SabySize + +private let STORAGE_VERSION = "Version1" +private let STORAGE_ENCODING_VERSION_KEY = "SabyCoreDataSetStorageEncodingVersion" +private let STORAGE_ENCODING_VERSION = 1 + +public final class CoreDataSetStorage: SetStorage { + typealias Context = NSManagedObjectContext + + let entity: NSEntityDescription + + let contextLoad: () -> Promise + let contextPromise: Atomic> + + let encoder: JSONEncoder = { + let encoder = JSONEncoder.acceptingNonConfirmingFloat() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + let decoder = JSONDecoder.acceptingNonConfirmingFloat() + + public init( + directoryURL: URL, + storageName: String, + migration: @escaping () -> Promise + ) { + let schema = SabyCoreDataSetStorageSchema() + + self.entity = schema.entity + self.contextLoad = { + Context.loadSetStorage( + directoryURL: directoryURL, + storageName: storageName, + migration: migration, + model: schema.model + ) + } + self.contextPromise = Atomic(contextLoad()) + } +} + +extension CoreDataSetStorage { + public func set(_ values: Set) -> Promise { + execute { context in + let encodedValues = try values.map { value -> Data in + try self.encoder.encode(value) + } + + try context.executeSetStorageDelete(self.createAnyRequest()) + + if !encodedValues.isEmpty { + try context.insertSetStorageItems( + encodedValues: encodedValues, + entity: self.entity + ) + } + + try context.markSetStorageEncodingCurrent() + } + } + + public func add(_ value: Value) -> Promise { + execute { context in + let data = try self.encoder.encode(value) + let request = self.createContainsRequest(data: data) + + if try context.fetch(request).isEmpty == false { + return + } + + let isEncodingCurrent = try context.isSetStorageEncodingCurrent() + var storedValueCount = 0 + + if !isEncodingCurrent { + let dictionaries = try context.fetch(self.createDataRequest()) + storedValueCount = dictionaries.count + + for dictionary in dictionaries { + guard let storedData = dictionary["data"] as? Data else { + throw CoreDataSetStorageError.requestResultNotFound + } + + if try self.decoder.decode(Value.self, from: storedData) == value { + return + } + } + } + + try context.insertSetStorageItems( + encodedValues: [data], + entity: self.entity + ) + + if !isEncodingCurrent && storedValueCount == 0 { + try context.markSetStorageEncodingCurrent() + } + } + } + + public func delete(_ value: Value) -> Promise { + execute { context in + let data = try self.encoder.encode(value) + try context.executeSetStorageDelete( + self.createAnyRequest(data: data) + ) + + guard try context.isSetStorageEncodingCurrent() == false else { + return + } + + let items = try context.fetch(self.createItemRequest()) + var hasChanges = false + + for item in items { + if try self.decoder.decode(Value.self, from: item.data) == value { + context.delete(item) + hasChanges = true + } + } + + if hasChanges { + try context.save() + } + } + } + + public func get() -> Promise, Error> { + execute { context in + let dictionaries = try context.fetch(self.createDataRequest()) + var values = Set(minimumCapacity: dictionaries.count) + + for dictionary in dictionaries { + guard let data = dictionary["data"] as? Data else { + throw CoreDataSetStorageError.requestResultNotFound + } + values.insert(try self.decoder.decode(Value.self, from: data)) + } + + return values + } + } + + public func contains(_ value: Value) -> Promise { + execute { context in + let data = try self.encoder.encode(value) + let request = self.createContainsRequest(data: data) + + if try context.fetch(request).isEmpty == false { + return true + } + + guard try context.isSetStorageEncodingCurrent() == false else { + return false + } + + let dictionaries = try context.fetch(self.createDataRequest()) + for dictionary in dictionaries { + guard let legacyData = dictionary["data"] as? Data else { + throw CoreDataSetStorageError.requestResultNotFound + } + + if try self.decoder.decode(Value.self, from: legacyData) == value { + return true + } + } + + return false + } + } + + public func clear() -> Promise { + execute { context in + try context.executeSetStorageDelete(self.createAnyRequest()) + try context.markSetStorageEncodingCurrent() + } + } +} + +extension CoreDataSetStorage { + public func count() -> Promise { + execute { context in + try context.count(for: self.createAnyRequest()) + } + } + + public func size() -> Promise { + execute { context in + let result = try context.fetch(self.createSizeRequest()) + let byte = result.first?["result"] as? NSNumber ?? 0 + + return Volume.byte(byte.doubleValue) + } + } +} + +extension CoreDataSetStorage { + fileprivate func createAnyRequest() -> NSFetchRequest { + let request = NSFetchRequest() + request.entity = entity + + return request + } + + fileprivate func createAnyRequest( + data: Data + ) -> NSFetchRequest { + let request = createAnyRequest() + request.predicate = NSPredicate(format: "data == %@", data as NSData) + + return request + } + + fileprivate func createItemRequest() -> NSFetchRequest { + let request = NSFetchRequest() + request.entity = entity + + return request + } + + fileprivate func createDataRequest() -> NSFetchRequest { + let request = NSFetchRequest() + request.entity = entity + request.propertiesToFetch = ["data"] + request.resultType = .dictionaryResultType + + return request + } + + fileprivate func createContainsRequest(data: Data) -> NSFetchRequest { + let request = NSFetchRequest() + request.entity = entity + request.predicate = NSPredicate(format: "data == %@", data as NSData) + request.fetchLimit = 1 + request.resultType = .managedObjectIDResultType + + return request + } + + fileprivate func createSizeRequest() -> NSFetchRequest { + let byteExpression = NSExpression(forKeyPath: \SabyCoreDataSetStorageItemVersion1.byte) + let sumExpression = NSExpression(forFunction: "sum:", arguments: [byteExpression]) + let sumDescription = NSExpressionDescription() + sumDescription.expression = sumExpression + sumDescription.name = "result" + sumDescription.expressionResultType = .integer64AttributeType + + let request = NSFetchRequest() + request.entity = entity + request.propertiesToFetch = [sumDescription] + request.resultType = .dictionaryResultType + + return request + } +} + +extension CoreDataSetStorage { + fileprivate func execute( + block: @escaping (Context) throws -> Result + ) -> Promise { + let loadPromiseCapture = contextPromise.mutate { + let capture = !$0.isRejected ? $0 : contextLoad() + return capture + } + + return loadPromiseCapture.then { context in + Promise { resolve, reject in + context.perform { + do { + resolve(try block(context)) + } catch { + reject(error) + } + } + } + } + } +} + +private extension NSManagedObjectContext { + func insertSetStorageItems( + encodedValues: [Data], + entity: NSEntityDescription + ) throws { + for data in encodedValues { + let item = SabyCoreDataSetStorageItemVersion1( + entity: entity, + insertInto: self + ) + item.data = data + item.byte = data.count + } + + do { + try save() + } catch { + rollback() + throw error + } + } + + func isSetStorageEncodingCurrent() throws -> Bool { + let metadata = try setStorageMetadata() + let version = metadata.values[STORAGE_ENCODING_VERSION_KEY] as? NSNumber + + return version?.intValue == STORAGE_ENCODING_VERSION + } + + func markSetStorageEncodingCurrent() throws { + var metadata = try setStorageMetadata() + metadata.values[STORAGE_ENCODING_VERSION_KEY] = STORAGE_ENCODING_VERSION + metadata.coordinator.setMetadata(metadata.values, for: metadata.store) + } + + func setStorageMetadata() throws -> ( + coordinator: NSPersistentStoreCoordinator, + store: NSPersistentStore, + values: [String: Any] + ) { + guard + let coordinator = persistentStoreCoordinator, + let store = coordinator.persistentStores.first + else { + throw CoreDataSetStorageError.persistentStoreNotFound + } + + return ( + coordinator, + store, + coordinator.metadata(for: store) + ) + } + + func executeSetStorageDelete( + _ request: NSFetchRequest + ) throws { + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) + deleteRequest.resultType = .resultTypeStatusOnly + + guard + let result = try execute(deleteRequest) as? NSBatchDeleteResult, + result.result as? Bool == true + else { + throw CoreDataSetStorageError.batchDeleteFailed + } + } + + static func loadSetStorage( + directoryURL: URL, + storageName: String, + migration: @escaping () -> Promise, + model: NSManagedObjectModel + ) -> Promise { + migration().then { + let fileManager = FileManager.default + + guard directoryURL.isFileURL else { + throw StorageError.directoryURLIsNotFileURL + } + + if !fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + } + + let url = directoryURL.appendingPathComponent("\(storageName)_\(STORAGE_VERSION)") + let container = NSPersistentContainer( + name: storageName, + managedObjectModel: model + ) + let storeDescription = NSPersistentStoreDescription(url: url) + container.persistentStoreDescriptions = [storeDescription] + + return Promise { resolve, reject in + container.loadPersistentStores { _, error in + if let error { + reject(error) + return + } + resolve(container.newBackgroundContext()) + } + } + } + } +} + +public enum CoreDataSetStorageError: Error { + case batchDeleteFailed + case persistentStoreNotFound + case requestResultNotFound +} + +@objc(SabyCoreDataSetStorageItemVersion1) +final class SabyCoreDataSetStorageItemVersion1: NSManagedObject { + @NSManaged var data: Data + @NSManaged var byte: Int +} + +final class SabyCoreDataSetStorageSchema { + let entity: NSEntityDescription + let model: NSManagedObjectModel + + init() { + let dataAttribute = NSAttributeDescription() + dataAttribute.name = "data" + if #available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) { + dataAttribute.type = .binaryData + } else { + dataAttribute.attributeType = .binaryDataAttributeType + } + + let byteAttribute = NSAttributeDescription() + byteAttribute.name = "byte" + if #available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) { + byteAttribute.type = .integer64 + } else { + byteAttribute.attributeType = .integer64AttributeType + } + + let itemEntity = NSEntityDescription() + itemEntity.name = String(describing: SabyCoreDataSetStorageItemVersion1.self) + itemEntity.managedObjectClassName = String(describing: SabyCoreDataSetStorageItemVersion1.self) + itemEntity.properties = [ + dataAttribute, + byteAttribute + ] + itemEntity.indexes = [ + NSFetchIndexDescription( + name: "SabyCoreDataSetStorageDataIndex", + elements: [ + NSFetchIndexElementDescription( + property: dataAttribute, + collationType: .binary + ) + ] + ) + ] + + let model = NSManagedObjectModel() + model.entities = [itemEntity] + + self.entity = itemEntity + self.model = model + } +} diff --git a/Source/AppleStorage/SetStorage.swift b/Source/AppleStorage/SetStorage.swift new file mode 100644 index 00000000..f10691d5 --- /dev/null +++ b/Source/AppleStorage/SetStorage.swift @@ -0,0 +1,22 @@ +// +// SetStorage.swift +// SabyAppleStorage +// + +import SabyConcurrency +import SabySize + +public protocol SetStorage: Storage { + associatedtype Value: Hashable + + func set(_ values: Set) -> Promise + func add(_ value: Value) -> Promise + + func delete(_ value: Value) -> Promise + func get() -> Promise, Error> + func contains(_ value: Value) -> Promise + func clear() -> Promise + + func count() -> Promise + func size() -> Promise +} diff --git a/Source/Network/Client.swift b/Source/Network/Client.swift index 0366d100..d835bca6 100644 --- a/Source/Network/Client.swift +++ b/Source/Network/Client.swift @@ -68,4 +68,8 @@ public enum ClientMethod: String { public typealias ClientHeader = Dictionary -public typealias ClientResult = (code2XX: Int, body: Response) +public typealias ClientResult = ( + code2XX: Int, + headers: ClientHeader, + body: Response +) diff --git a/Source/Network/Implement/Client/DataClient.swift b/Source/Network/Implement/Client/DataClient.swift index 5486eb74..504d033a 100644 --- a/Source/Network/Implement/Client/DataClient.swift +++ b/Source/Network/Implement/Client/DataClient.swift @@ -84,7 +84,11 @@ extension DataClient { return } - pending.resolve((code2XX: code, body: data)) + pending.resolve(( + code2XX: code, + headers: response.headers, + body: data + )) } pending.onCancel { task.cancel() @@ -132,3 +136,12 @@ public enum DataClientError: Error { case statusCodeNotFound case statusCodeNot2XX(codeNot2XX: Int, body: Data?) } + +private extension HTTPURLResponse { + var headers: ClientHeader { + allHeaderFields.reduce(into: [:]) { headers, field in + guard let key = field.key as? String else { return } + headers[key] = String(describing: field.value) + } + } +} diff --git a/Source/Network/Implement/Client/JSONClient.swift b/Source/Network/Implement/Client/JSONClient.swift index 2874cfbb..b82b7ecb 100644 --- a/Source/Network/Implement/Client/JSONClient.swift +++ b/Source/Network/Implement/Client/JSONClient.swift @@ -60,13 +60,13 @@ extension JSONClient { timeout: timeout, optionBlock: optionBlock ) - .then { code2XX, data -> ClientResult in + .then { code2XX, headers, data -> ClientResult in guard let data, let body = try? JSON.parse(data) else { throw JSONClientError.responseDataIsNotDecodable(code: code2XX, body: data) } - return (code2XX, body) + return (code2XX, headers, body) } .catch { error in if case DataClientError.timeout = error { diff --git a/Source/TestMock/MockURLProtocol.swift b/Source/TestMock/MockURLProtocol.swift index 93d086cf..e7df436d 100644 --- a/Source/TestMock/MockURLProtocol.swift +++ b/Source/TestMock/MockURLProtocol.swift @@ -72,12 +72,27 @@ public struct URLResult { let data: Promise let error: Error? - public init(url: URL, code: Int, data: Data?) { - self.init(url: url, code: code, data: .resolved(data)) + public init( + url: URL, + code: Int, + headers: [String: String] = [:], + data: Data? + ) { + self.init(url: url, code: code, headers: headers, data: .resolved(data)) } - public init(url: URL, code: Int, data: Promise) { - let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil) + public init( + url: URL, + code: Int, + headers: [String: String] = [:], + data: Promise + ) { + let response = HTTPURLResponse( + url: url, + statusCode: code, + httpVersion: nil, + headerFields: headers + ) self.url = url self.response = response @@ -85,12 +100,27 @@ public struct URLResult { self.error = nil } - public init(url: URL, code: Int, json: JSON) { - self.init(url: url, code: code, json: .resolved(json)) + public init( + url: URL, + code: Int, + headers: [String: String] = [:], + json: JSON + ) { + self.init(url: url, code: code, headers: headers, json: .resolved(json)) } - public init(url: URL, code: Int, json: Promise) { - let response = HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil) + public init( + url: URL, + code: Int, + headers: [String: String] = [:], + json: Promise + ) { + let response = HTTPURLResponse( + url: url, + statusCode: code, + httpVersion: nil, + headerFields: headers + ) self.url = url self.response = response diff --git a/Test/AppleStorage/Implement/CoreDataSetStorageTest.swift b/Test/AppleStorage/Implement/CoreDataSetStorageTest.swift new file mode 100644 index 00000000..d8d5d33a --- /dev/null +++ b/Test/AppleStorage/Implement/CoreDataSetStorageTest.swift @@ -0,0 +1,195 @@ +// +// CoreDataSetStorageTest.swift +// SabyAppleStorageTest +// + +import CoreData +import XCTest +@testable import SabyAppleStorage + +private struct SetValue: Codable, Hashable { + let id: Int +} + +private struct LegacySetValue: Codable, Hashable { + let first: Int + let second: Int +} + +final class CoreDataSetStorageTest: XCTestCase { + private var storage: CoreDataSetStorage! + + override func setUpWithError() throws { + storage = CoreDataSetStorage( + directoryURL: FileManager.default.temporaryDirectory, + storageName: "\(UUID())" + ) + } + + override func tearDownWithError() throws { + try storage.clear().wait() + } + + func test__set_replaces_existing_values() throws { + try storage.set([SetValue(id: 0), SetValue(id: 1)]).wait() + + let expected: Set = [SetValue(id: 2), SetValue(id: 3)] + try storage.set(expected).wait() + + XCTAssertEqual(try storage.get().wait(), expected) + XCTAssertEqual(try storage.count().wait(), expected.count) + } + + func test__set_empty_clears_existing_values() throws { + try storage.set([SetValue(id: 0)]).wait() + + try storage.set([]).wait() + + XCTAssertEqual(try storage.get().wait(), []) + XCTAssertEqual(try storage.count().wait(), 0) + XCTAssertEqual(try storage.size().wait().byte, 0) + } + + func test__add() throws { + let existingValue = SetValue(id: 0) + let newValue = SetValue(id: 1) + try storage.set([existingValue]).wait() + + try storage.add(existingValue).wait() + try storage.add(newValue).wait() + + XCTAssertEqual(try storage.get().wait(), [existingValue, newValue]) + XCTAssertEqual(try storage.count().wait(), 2) + } + + func test__delete() throws { + let deletedValue = SetValue(id: 0) + let remainingValue = SetValue(id: 1) + try storage.set([deletedValue, remainingValue]).wait() + + try storage.delete(deletedValue).wait() + try storage.delete(SetValue(id: 2)).wait() + + XCTAssertEqual(try storage.get().wait(), [remainingValue]) + XCTAssertEqual(try storage.count().wait(), 1) + } + + func test__contains() throws { + let existingValue = SetValue(id: 0) + try storage.set([existingValue]).wait() + + XCTAssertTrue(try storage.contains(existingValue).wait()) + XCTAssertFalse(try storage.contains(SetValue(id: 1)).wait()) + } + + func test__contains_value_from_legacy_local_database() throws { + let storageName = "\(UUID())" + let legacyData = Data(#"{"second":2,"first":1}"#.utf8) + try createLegacyStorage(storageName: storageName, data: legacyData) + + let storage = CoreDataSetStorage( + directoryURL: FileManager.default.temporaryDirectory, + storageName: storageName + ) + let existingValue = LegacySetValue(first: 1, second: 2) + + XCTAssertEqual(try storage.get().wait(), [existingValue]) + XCTAssertTrue(try storage.contains(existingValue).wait()) + XCTAssertFalse( + try storage.contains(LegacySetValue(first: 1, second: 3)).wait() + ) + + let newValue = LegacySetValue(first: 3, second: 4) + try storage.add(existingValue).wait() + try storage.add(newValue).wait() + XCTAssertEqual(try storage.get().wait(), [existingValue, newValue]) + + try storage.delete(existingValue).wait() + XCTAssertEqual(try storage.get().wait(), [newValue]) + try storage.delete(newValue).wait() + XCTAssertEqual(try storage.get().wait(), []) + + try storage.set([existingValue]).wait() + XCTAssertTrue(try storage.contains(existingValue).wait()) + try storage.clear().wait() + } + + func test__set_and_get_100_000_values() throws { + let expected = Set((0 ..< 100_000).map(SetValue.init(id:))) + + try storage.set(expected).wait() + let actual = try storage.get().wait() + + XCTAssertEqual(actual, expected) + XCTAssertEqual(try storage.count().wait(), expected.count) + XCTAssertTrue(try storage.contains(SetValue(id: 99_999)).wait()) + XCTAssertFalse(try storage.contains(SetValue(id: 100_000)).wait()) + } +} + +private extension CoreDataSetStorageTest { + func createLegacyStorage(storageName: String, data: Data) throws { + let schema = LegacyCoreDataSetStorageSchema() + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(storageName)_Version1") + let container = NSPersistentContainer( + name: storageName, + managedObjectModel: schema.model + ) + container.persistentStoreDescriptions = [ + NSPersistentStoreDescription(url: url) + ] + + let loadExpectation = expectation(description: "load legacy persistent store") + var loadError: Error? + container.loadPersistentStores { _, error in + loadError = error + loadExpectation.fulfill() + } + wait(for: [loadExpectation], timeout: 5) + if let loadError { + throw loadError + } + + let context = container.newBackgroundContext() + try context.performAndWait { + let item = SabyCoreDataSetStorageItemVersion1( + entity: schema.entity, + insertInto: context + ) + item.data = data + item.byte = data.count + try context.save() + } + + for store in container.persistentStoreCoordinator.persistentStores { + try container.persistentStoreCoordinator.remove(store) + } + } +} + +private final class LegacyCoreDataSetStorageSchema { + let entity: NSEntityDescription + let model: NSManagedObjectModel + + init() { + let dataAttribute = NSAttributeDescription() + dataAttribute.name = "data" + dataAttribute.attributeType = .binaryDataAttributeType + + let byteAttribute = NSAttributeDescription() + byteAttribute.name = "byte" + byteAttribute.attributeType = .integer64AttributeType + + let entity = NSEntityDescription() + entity.name = "SabyCoreDataSetStorageItemVersion1" + entity.managedObjectClassName = "SabyCoreDataSetStorageItemVersion1" + entity.properties = [dataAttribute, byteAttribute] + + let model = NSManagedObjectModel() + model.entities = [entity] + + self.entity = entity + self.model = model + } +} diff --git a/Test/Network/DataClientTest.swift b/Test/Network/DataClientTest.swift index 4845ad22..38180090 100644 --- a/Test/Network/DataClientTest.swift +++ b/Test/Network/DataClientTest.swift @@ -36,6 +36,7 @@ final class DataClientTest: XCTestCase { URLResult( url: URL(string: "https://mock.api.ab180.co/request")!, code: 200, + headers: ["X-Response-ID": "success"], data: Data() ) ] @@ -46,7 +47,13 @@ final class DataClientTest: XCTestCase { let response = client.request(URL(string: "https://mock.api.ab180.co/request")!) - Expect.promise(response, state: .resolved({ $0 == (200, Data()) }), timeout: .seconds(2)) + Expect.promise( + response, + state: .resolved({ + $0 == (200, ["X-Response-ID": "success"], Data()) + }), + timeout: .seconds(2) + ) } func test__request_reponse_code_not_2XX() { diff --git a/Test/Network/JSONClientTest.swift b/Test/Network/JSONClientTest.swift index f374acdb..c0d224bd 100644 --- a/Test/Network/JSONClientTest.swift +++ b/Test/Network/JSONClientTest.swift @@ -37,6 +37,7 @@ final class JSONClientTest: XCTestCase { URLResult( url: URL(string: "https://mock.api.ab180.co/request")!, code: 200, + headers: ["X-Response-ID": "success"], data: try! JSON.from([:]).datafy() ) ] @@ -52,7 +53,9 @@ final class JSONClientTest: XCTestCase { Expect.promise( response, - state: .resolved({ $0 == (200, [:]) }), + state: .resolved({ + $0 == (200, ["X-Response-ID": "success"], [:]) + }), timeout: .seconds(2) ) }