From 31050be22a0234eb091119d4ba2f4b6323056ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elvis=20Nu=C3=B1ez?= Date: Thu, 16 Mar 2017 19:34:47 +0100 Subject: [PATCH] Add files --- Source/DataFilter/DataFilter.swift | 90 ++++ Source/DataStack/DataStack.swift | 470 ++++++++++++++++++ Source/DateParser/NSDate+SyncPropertyMapper.h | 82 +++ Source/DateParser/NSDate+SyncPropertyMapper.m | 249 ++++++++++ .../NSEntityDescription+SyncPrimaryKey.h | 42 ++ .../NSEntityDescription+SyncPrimaryKey.m | 52 ++ ...SManagedObject+SyncPropertyMapperHelpers.h | 46 ++ ...SManagedObject+SyncPropertyMapperHelpers.m | 286 +++++++++++ .../SyncPropertyMapper.h | 149 ++++++ .../SyncPropertyMapper.m | 291 +++++++++++ .../NSString+SyncInflections.h | 9 + .../NSString+SyncInflections.m | 208 ++++++++ Source/Sync.h | 10 + Source/Sync/NSArray+Sync.swift | 37 ++ Source/Sync/NSEntityDescription+Sync.swift | 26 + Source/Sync/NSManagedObject+Sync.swift | 375 ++++++++++++++ Source/Sync/NSManagedObjectContext+Sync.swift | 66 +++ Source/Sync/Result.swift | 102 ++++ Source/Sync/Sync+DataStack.swift | 115 +++++ Source/Sync/Sync+NSPersistentContainer.swift | 151 ++++++ Source/Sync/Sync.swift | 317 ++++++++++++ Source/TestCheck/TestCheck.swift | 56 +++ 22 files changed, 3229 insertions(+) create mode 100755 Source/DataFilter/DataFilter.swift create mode 100755 Source/DataStack/DataStack.swift create mode 100755 Source/DateParser/NSDate+SyncPropertyMapper.h create mode 100755 Source/DateParser/NSDate+SyncPropertyMapper.m create mode 100755 Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.h create mode 100755 Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.m create mode 100755 Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.h create mode 100755 Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.m create mode 100755 Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.h create mode 100755 Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.m create mode 100755 Source/NSString-SyncInflections/NSString+SyncInflections.h create mode 100755 Source/NSString-SyncInflections/NSString+SyncInflections.m create mode 100644 Source/Sync.h create mode 100644 Source/Sync/NSArray+Sync.swift create mode 100644 Source/Sync/NSEntityDescription+Sync.swift create mode 100644 Source/Sync/NSManagedObject+Sync.swift create mode 100644 Source/Sync/NSManagedObjectContext+Sync.swift create mode 100644 Source/Sync/Result.swift create mode 100644 Source/Sync/Sync+DataStack.swift create mode 100644 Source/Sync/Sync+NSPersistentContainer.swift create mode 100644 Source/Sync/Sync.swift create mode 100755 Source/TestCheck/TestCheck.swift diff --git a/Source/DataFilter/DataFilter.swift b/Source/DataFilter/DataFilter.swift new file mode 100755 index 00000000..85139cb9 --- /dev/null +++ b/Source/DataFilter/DataFilter.swift @@ -0,0 +1,90 @@ +import Foundation +import CoreData + +/** + Helps you filter insertions, deletions and updates by comparing your JSON dictionary with your Core Data local objects. + It also provides uniquing for you locally stored objects and automatic removal of not found ones. + */ +class DataFilter: NSObject { + struct Operation: OptionSet { + let rawValue: Int + + init(rawValue: Int) { + self.rawValue = rawValue + } + + static let insert = Operation(rawValue: 1 << 0) + static let update = Operation(rawValue: 1 << 1) + static let delete = Operation(rawValue: 1 << 2) + static let all: Operation = [.insert, .update, .delete] + } + + class func changes(_ changes: [[String: Any]], + inEntityNamed entityName: String, + localPrimaryKey: String, + remotePrimaryKey: String, + context: NSManagedObjectContext, + inserted: (_ json: [String: Any]) -> Void, + updated: (_ json: [String: Any], _ updatedObject: NSManagedObject) -> Void) { + self.changes(changes, inEntityNamed: entityName, predicate: nil, operations: .all, localPrimaryKey: localPrimaryKey, remotePrimaryKey: remotePrimaryKey, context: context, inserted: inserted, updated: updated) + } + + class func changes(_ changes: [[String: Any]], + inEntityNamed entityName: String, + predicate: NSPredicate?, + operations: Operation, + localPrimaryKey: String, + remotePrimaryKey: String, + context: NSManagedObjectContext, + inserted: (_ json: [String: Any]) -> Void, + updated: (_ json: [String: Any], _ updatedObject: NSManagedObject) -> Void) { + // `DataObjectIDs.objectIDsInEntityNamed` also deletes all objects that don't have a primary key or that have the same primary key already found in the context + let primaryKeysAndObjectIDs = context.managedObjectIDs(in: entityName, usingAsKey: localPrimaryKey, predicate: predicate) as [NSObject: NSManagedObjectID] + let localPrimaryKeys = Array(primaryKeysAndObjectIDs.keys) + let remotePrimaryKeys = changes.map { $0[remotePrimaryKey] } + let remotePrimaryKeysWithoutNils = (remotePrimaryKeys.filter { (($0 as? NSObject) != NSNull()) && ($0 != nil) } as! [NSObject?]) as! [NSObject] + + var remotePrimaryKeysAndChanges = [NSObject: [String: Any]]() + for (primaryKey, change) in zip(remotePrimaryKeysWithoutNils, changes) { + remotePrimaryKeysAndChanges[primaryKey] = change + } + + var intersection = Set(remotePrimaryKeysWithoutNils) + intersection.formIntersection(Set(localPrimaryKeys)) + let updatedObjectIDs = Array(intersection) + + var deletedObjectIDs = localPrimaryKeys + deletedObjectIDs = deletedObjectIDs.filter { value in + !remotePrimaryKeysWithoutNils.contains { $0.isEqual(value) } + } + + var insertedObjectIDs = remotePrimaryKeysWithoutNils + insertedObjectIDs = insertedObjectIDs.filter { value in + !localPrimaryKeys.contains { $0.isEqual(value) } + } + + if operations.contains(.delete) { + for fetchedID in deletedObjectIDs { + let objectID = primaryKeysAndObjectIDs[fetchedID]! + let object = context.object(with: objectID) + context.delete(object) + } + } + + if operations.contains(.insert) { + for fetchedID in insertedObjectIDs { + let objectDictionary = remotePrimaryKeysAndChanges[fetchedID]! + inserted(objectDictionary) + } + } + + if operations.contains(.update) { + for fetchedID in updatedObjectIDs { + let JSON = remotePrimaryKeysAndChanges[fetchedID]! + let objectID = primaryKeysAndObjectIDs[fetchedID]! + let object = context.object(with: objectID) + updated(JSON, object) + } + } + } +} diff --git a/Source/DataStack/DataStack.swift b/Source/DataStack/DataStack.swift new file mode 100755 index 00000000..36544ac6 --- /dev/null +++ b/Source/DataStack/DataStack.swift @@ -0,0 +1,470 @@ +import Foundation +import CoreData + +@objc public enum DataStackStoreType: Int { + case inMemory, sqLite + + var type: String { + switch self { + case .inMemory: + return NSInMemoryStoreType + case .sqLite: + return NSSQLiteStoreType + } + } +} + +@objc public class DataStack: NSObject { + private var storeType = DataStackStoreType.sqLite + + private var storeName: String? + + private var modelName = "" + + private var modelBundle = Bundle.main + + private var model: NSManagedObjectModel + + private var containerURL = URL.directoryURL() + + private var _mainContext: NSManagedObjectContext? + + /** + The context for the main queue. Please do not use this to mutate data, use `performInNewBackgroundContext` + instead. + */ + public lazy var mainContext: NSManagedObjectContext = { + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.undoManager = nil + context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + context.persistentStoreCoordinator = self.persistentStoreCoordinator + + NotificationCenter.default.addObserver(self, selector: #selector(DataStack.mainContextDidSave(_:)), name: .NSManagedObjectContextDidSave, object: context) + + return context + }() + + /** + The context for the main queue. Please do not use this to mutate data, use `performBackgroundTask` + instead. + */ + public var viewContext: NSManagedObjectContext { + return self.mainContext + } + + private lazy var writerContext: NSManagedObjectContext = { + let context = NSManagedObjectContext(concurrencyType: DataStack.backgroundConcurrencyType()) + context.undoManager = nil + context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + context.persistentStoreCoordinator = self.persistentStoreCoordinator + + return context + }() + + private lazy var persistentStoreCoordinator: NSPersistentStoreCoordinator = { + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: self.model) + try! persistentStoreCoordinator.addPersistentStore(storeType: self.storeType, bundle: self.modelBundle, modelName: self.modelName, storeName: self.storeName, containerURL: self.containerURL) + + return persistentStoreCoordinator + }() + + private lazy var disposablePersistentStoreCoordinator: NSPersistentStoreCoordinator = { + let model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: model) + try! persistentStoreCoordinator.addPersistentStore(storeType: .inMemory, bundle: self.modelBundle, modelName: self.modelName, storeName: self.storeName, containerURL: self.containerURL) + + return persistentStoreCoordinator + }() + + /** + Initializes a DataStack using the bundle name as the model name, so if your target is called ModernApp, + it will look for a ModernApp.xcdatamodeld. + */ + public override init() { + let bundle = Bundle.main + if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String { + self.modelName = bundleName + } + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name. + - parameter modelName: The name of your Core Data model (xcdatamodeld). + */ + public init(modelName: String) { + self.modelName = modelName + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name, bundle and storeType. + - parameter modelName: The name of your Core Data model (xcdatamodeld). + - parameter storeType: The store type to be used, you have .InMemory and .SQLite, the first one is memory + based and doesn't save to disk, while the second one creates a .sqlite file and stores things there. + */ + public init(modelName: String, storeType: DataStackStoreType) { + self.modelName = modelName + self.storeType = storeType + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name, bundle and storeType. + - parameter modelName: The name of your Core Data model (xcdatamodeld). + - parameter bundle: The bundle where your Core Data model is located, normally your Core Data model is in + the main bundle but when using unit tests sometimes your Core Data model could be located where your tests + are located. + - parameter storeType: The store type to be used, you have .InMemory and .SQLite, the first one is memory + based and doesn't save to disk, while the second one creates a .sqlite file and stores things there. + */ + public init(modelName: String, bundle: Bundle, storeType: DataStackStoreType) { + self.modelName = modelName + self.modelBundle = bundle + self.storeType = storeType + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name, bundle, storeType and store name. + - parameter modelName: The name of your Core Data model (xcdatamodeld). + - parameter bundle: The bundle where your Core Data model is located, normally your Core Data model is in + the main bundle but when using unit tests sometimes your Core Data model could be located where your tests + are located. + - parameter storeType: The store type to be used, you have .InMemory and .SQLite, the first one is memory + based and doesn't save to disk, while the second one creates a .sqlite file and stores things there. + - parameter storeName: Normally your file would be named as your model name is named, so if your model + name is AwesomeApp then the .sqlite file will be named AwesomeApp.sqlite, this attribute allows your to + change that. + */ + public init(modelName: String, bundle: Bundle, storeType: DataStackStoreType, storeName: String) { + self.modelName = modelName + self.modelBundle = bundle + self.storeType = storeType + self.storeName = storeName + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name, bundle, storeType and store name. + - parameter modelName: The name of your Core Data model (xcdatamodeld). + - parameter bundle: The bundle where your Core Data model is located, normally your Core Data model is in + the main bundle but when using unit tests sometimes your Core Data model could be located where your tests + are located. + - parameter storeType: The store type to be used, you have .InMemory and .SQLite, the first one is memory + based and doesn't save to disk, while the second one creates a .sqlite file and stores things there. + - parameter storeName: Normally your file would be named as your model name is named, so if your model + name is AwesomeApp then the .sqlite file will be named AwesomeApp.sqlite, this attribute allows your to + change that. + - parameter containerURL: The container URL for the sqlite file when a store type of SQLite is used. + */ + public init(modelName: String, bundle: Bundle, storeType: DataStackStoreType, storeName: String, containerURL: URL) { + self.modelName = modelName + self.modelBundle = bundle + self.storeType = storeType + self.storeName = storeName + self.containerURL = containerURL + self.model = NSManagedObjectModel(bundle: self.modelBundle, name: self.modelName) + + super.init() + } + + /** + Initializes a DataStack using the provided model name, bundle and storeType. + - parameter model: The model that we'll use to set up your DataStack. + - parameter storeType: The store type to be used, you have .InMemory and .SQLite, the first one is memory + based and doesn't save to disk, while the second one creates a .sqlite file and stores things there. + */ + public init(model: NSManagedObjectModel, storeType: DataStackStoreType) { + self.model = model + self.storeType = storeType + + let bundle = Bundle.main + if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String { + self.storeName = bundleName + } + + super.init() + } + + deinit { + NotificationCenter.default.removeObserver(self, name: .NSManagedObjectContextWillSave, object: nil) + NotificationCenter.default.removeObserver(self, name: .NSManagedObjectContextDidSave, object: nil) + } + + /** + Returns a new main context that is detached from saving to disk. + */ + public func newDisposableMainContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.persistentStoreCoordinator = self.disposablePersistentStoreCoordinator + context.undoManager = nil + + NotificationCenter.default.addObserver(self, selector: #selector(DataStack.newDisposableMainContextWillSave(_:)), name: NSNotification.Name.NSManagedObjectContextWillSave, object: context) + + return context + } + + /** + Returns a background context perfect for data mutability operations. Make sure to never use it on the main thread. Use `performBlock` or `performBlockAndWait` to use it. + Saving to this context doesn't merge with the main thread. This context is specially useful to run operations that don't block the main thread. To refresh your main thread objects for + example when using a NSFetchedResultsController use `try self.fetchedResultsController.performFetch()`. + */ + public func newNonMergingBackgroundContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: DataStack.backgroundConcurrencyType()) + context.persistentStoreCoordinator = self.persistentStoreCoordinator + context.undoManager = nil + context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + + return context + } + + /** + Returns a background context perfect for data mutability operations. Make sure to never use it on the main thread. Use `performBlock` or `performBlockAndWait` to use it. + */ + public func newBackgroundContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: DataStack.backgroundConcurrencyType()) + context.persistentStoreCoordinator = self.persistentStoreCoordinator + context.undoManager = nil + context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + + NotificationCenter.default.addObserver(self, selector: #selector(DataStack.backgroundContextDidSave(_:)), name: .NSManagedObjectContextDidSave, object: context) + + return context + } + + /** + Returns a background context perfect for data mutability operations. + - parameter operation: The block that contains the created background context. + */ + public func performInNewBackgroundContext(_ operation: @escaping (_ backgroundContext: NSManagedObjectContext) -> Void) { + let context = self.newBackgroundContext() + let contextBlock: @convention(block) () -> Void = { + operation(context) + } + let blockObject: AnyObject = unsafeBitCast(contextBlock, to: AnyObject.self) + context.perform(DataStack.performSelectorForBackgroundContext(), with: blockObject) + } + + /** + Returns a background context perfect for data mutability operations. + - parameter operation: The block that contains the created background context. + */ + public func performBackgroundTask(operation: @escaping (_ backgroundContext: NSManagedObjectContext) -> Void) { + self.performInNewBackgroundContext(operation) + } + + func saveMainThread(completion: ((_ error: NSError?) -> Void)?) { + var writerContextError: NSError? + let writerContextBlock: @convention(block) () -> Void = { + do { + try self.writerContext.save() + if TestCheck.isTesting { + completion?(nil) + } + } catch let parentError as NSError { + writerContextError = parentError + } + } + let writerContextBlockObject: AnyObject = unsafeBitCast(writerContextBlock, to: AnyObject.self) + + let mainContextBlock: @convention(block) () -> Void = { + self.writerContext.perform(DataStack.performSelectorForBackgroundContext(), with: writerContextBlockObject) + DispatchQueue.main.async { + completion?(writerContextError) + } + } + let mainContextBlockObject: AnyObject = unsafeBitCast(mainContextBlock, to: AnyObject.self) + self.mainContext.perform(DataStack.performSelectorForBackgroundContext(), with: mainContextBlockObject) + } + + /** + Drops the database. + */ + public func drop(completion: ((_ error: NSError?) -> Void)? = nil) { + self.writerContext.performAndWait { + self.writerContext.reset() + + self.mainContext.performAndWait { + self.mainContext.reset() + + self.persistentStoreCoordinator.performAndWait { + for store in self.persistentStoreCoordinator.persistentStores { + guard let storeURL = store.url else { continue } + + do { + try self.persistentStoreCoordinator.destroyPersistentStore(at: storeURL, ofType: self.storeType.type, options: store.options) + try! self.persistentStoreCoordinator.addPersistentStore(storeType: self.storeType, bundle: self.modelBundle, modelName: self.modelName, storeName: self.storeName, containerURL: self.containerURL) + + DispatchQueue.main.async { + completion?(nil) + } + } catch let error as NSError { + DispatchQueue.main.async { + completion?(NSError(info: "Failed dropping the data stack.", previousError: error)) + } + } + } + } + } + } + } + + /// Sends a request to all the persistent stores associated with the receiver. + /// + /// - Parameters: + /// - request: A fetch, save or delete request. + /// - context: The context against which request should be executed. + /// - Returns: An array containing managed objects, managed object IDs, or dictionaries as appropriate for a fetch request; an empty array if request is a save request, or nil if an error occurred. + /// - Throws: If an error occurs, upon return contains an NSError object that describes the problem. + public func execute(_ request: NSPersistentStoreRequest, with context: NSManagedObjectContext) throws -> Any { + return try self.persistentStoreCoordinator.execute(request, with: context) + } + + // Can't be private, has to be internal in order to be used as a selector. + func mainContextDidSave(_ notification: Notification) { + self.saveMainThread { error in + if let error = error { + fatalError("Failed to save objects in main thread: \(error)") + } + } + } + + // Can't be private, has to be internal in order to be used as a selector. + func newDisposableMainContextWillSave(_ notification: Notification) { + if let context = notification.object as? NSManagedObjectContext { + context.reset() + } + } + + // Can't be private, has to be internal in order to be used as a selector. + func backgroundContextDidSave(_ notification: Notification) throws { + if Thread.isMainThread && TestCheck.isTesting == false { + throw NSError(info: "Background context saved in the main thread. Use context's `performBlock`", previousError: nil) + } else { + let contextBlock: @convention(block) () -> Void = { + self.mainContext.mergeChanges(fromContextDidSave: notification) + } + let blockObject: AnyObject = unsafeBitCast(contextBlock, to: AnyObject.self) + self.mainContext.perform(DataStack.performSelectorForBackgroundContext(), with: blockObject) + } + } + + private static func backgroundConcurrencyType() -> NSManagedObjectContextConcurrencyType { + return TestCheck.isTesting ? .mainQueueConcurrencyType : .privateQueueConcurrencyType + } + + private static func performSelectorForBackgroundContext() -> Selector { + return TestCheck.isTesting ? NSSelectorFromString("performBlockAndWait:") : NSSelectorFromString("performBlock:") + } +} + +extension NSPersistentStoreCoordinator { + func addPersistentStore(storeType: DataStackStoreType, bundle: Bundle, modelName: String, storeName: String?, containerURL: URL) throws { + let filePath = (storeName ?? modelName) + ".sqlite" + switch storeType { + case .inMemory: + do { + try self.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) + } catch let error as NSError { + throw NSError(info: "There was an error creating the persistentStoreCoordinator for in memory store", previousError: error) + } + + break + case .sqLite: + let storeURL = containerURL.appendingPathComponent(filePath) + let storePath = storeURL.path + + let shouldPreloadDatabase = !FileManager.default.fileExists(atPath: storePath) + if shouldPreloadDatabase { + if let preloadedPath = bundle.path(forResource: modelName, ofType: "sqlite") { + let preloadURL = URL(fileURLWithPath: preloadedPath) + + do { + try FileManager.default.copyItem(at: preloadURL, to: storeURL) + } catch let error as NSError { + throw NSError(info: "Oops, could not copy preloaded data", previousError: error) + } + } + } + + let options = [NSMigratePersistentStoresAutomaticallyOption: true, NSInferMappingModelAutomaticallyOption: true] + do { + try self.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options) + } catch { + do { + try FileManager.default.removeItem(atPath: storePath) + do { + try self.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: options) + } catch let addPersistentError as NSError { + throw NSError(info: "There was an error creating the persistentStoreCoordinator", previousError: addPersistentError) + } + } catch let removingError as NSError { + throw NSError(info: "There was an error removing the persistentStoreCoordinator", previousError: removingError) + } + } + + let shouldExcludeSQLiteFromBackup = storeType == .sqLite && TestCheck.isTesting == false + if shouldExcludeSQLiteFromBackup { + do { + try (storeURL as NSURL).setResourceValue(true, forKey: .isExcludedFromBackupKey) + } catch let excludingError as NSError { + throw NSError(info: "Excluding SQLite file from backup caused an error", previousError: excludingError) + } + } + + break + } + } +} + +extension NSManagedObjectModel { + convenience init(bundle: Bundle, name: String) { + if let momdModelURL = bundle.url(forResource: name, withExtension: "momd") { + self.init(contentsOf: momdModelURL)! + } else if let momModelURL = bundle.url(forResource: name, withExtension: "mom") { + self.init(contentsOf: momModelURL)! + } else { + self.init() + } + } +} + +extension NSError { + convenience init(info: String, previousError: NSError?) { + if let previousError = previousError { + var userInfo = previousError.userInfo + if let _ = userInfo[NSLocalizedFailureReasonErrorKey] { + userInfo["Additional reason"] = info + } else { + userInfo[NSLocalizedFailureReasonErrorKey] = info + } + + self.init(domain: previousError.domain, code: previousError.code, userInfo: userInfo) + } else { + var userInfo = [String: String]() + userInfo[NSLocalizedDescriptionKey] = info + self.init(domain: "com.SyncDB.DataStack", code: 9999, userInfo: userInfo) + } + } +} + +extension URL { + fileprivate static func directoryURL() -> URL { + #if os(tvOS) + return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).last! + #else + return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last! + #endif + } +} diff --git a/Source/DateParser/NSDate+SyncPropertyMapper.h b/Source/DateParser/NSDate+SyncPropertyMapper.h new file mode 100755 index 00000000..b30eefa1 --- /dev/null +++ b/Source/DateParser/NSDate+SyncPropertyMapper.h @@ -0,0 +1,82 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const DateParserDateNoTimestampFormat = @"YYYY-MM-DD"; +static NSString * const DateParserTimestamp = @"T00:00:00+00:00"; +static NSString * const DateParserDescriptionDate = @"0000-00-00 00:00:00"; + +/** + The type of date. + + - iso8601: International date standard. + - unixTimestamp: Number of seconds since Thursday, 1 January 1970. + */ +typedef NS_ENUM(NSInteger, DateType) { + iso8601, + unixTimestamp +}; + +@interface NSDate (SyncPropertyMapper) + +/** + Converts the provided string into a NSDate object. + + @param dateString The string to be converted, can be a ISO 8601 date or a Unix timestamp, also known as Epoch time. + + @return The parsed date. + */ ++ (NSDate * _Nullable)dateFromDateString:(NSString *)dateString; + +/** + Converts the provided Unix timestamp into a NSDate object. + + @param unixTimestamp The Unix timestamp to be used, also known as Epoch time. This value shouldn't be more than NSIntegerMax (2,147,483,647). + + @return The parsed date. + */ ++ (NSDate * _Nullable)dateFromUnixTimestampString:(NSString *)unixTimestamp; + +/** + Converts the provided Unix timestamp into a NSDate object. + + @param unixTimestamp The Unix timestamp to be used, also known as Epoch time. This value shouldn't be more than NSIntegerMax (2,147,483,647). + + @return The parsed date. + */ ++ (NSDate * _Nullable)dateFromUnixTimestampNumber:(NSNumber *)unixTimestamp; + +/** + Converts the provided ISO 8601 string representation into a NSDate object. The ISO string can have the structure of any of the following values: + @code + 2014-01-02 + 2016-01-09T00:00:00 + 2014-03-30T09:13:00Z + 2016-01-09T00:00:00.00 + 2015-06-23T19:04:19.911Z + 2014-01-01T00:00:00+00:00 + 2015-09-10T00:00:00.184968Z + 2015-09-10T00:00:00.116+0000 + 2015-06-23T14:40:08.000+02:00 + 2014-01-02T00:00:00.000000+00:00 + @endcode + + @param iso8601 The ISO 8601 date as NSString. + + @return The parsed date. + */ ++ (NSDate * _Nullable)dateFromISO8601String:(NSString *)iso8601; + +@end + + +/** + String extension to check wethere a string represents a ISO 8601 date or a Unix timestamp one. + */ +@interface NSString (Parser) + +- (DateType)dateType; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/DateParser/NSDate+SyncPropertyMapper.m b/Source/DateParser/NSDate+SyncPropertyMapper.m new file mode 100755 index 00000000..798110e1 --- /dev/null +++ b/Source/DateParser/NSDate+SyncPropertyMapper.m @@ -0,0 +1,249 @@ +#import "NSDate+SyncPropertyMapper.h" + +@implementation NSDate (SyncPropertyMapper) + ++ (NSDate *)dateFromDateString:(NSString *)dateString { + NSDate *parsedDate = nil; + + DateType dateType = [dateString dateType]; + switch (dateType) { + case iso8601: { + parsedDate = [self dateFromISO8601String:dateString]; + } break; + case unixTimestamp: { + parsedDate = [self dateFromUnixTimestampString:dateString]; + } break; + default: break; + } + + return parsedDate; +} + ++ (NSDate *)dateFromISO8601String:(NSString *)dateString { + if (!dateString || [dateString isEqual:[NSNull null]]) { + return nil; + } + + // Parse string + else if ([dateString isKindOfClass:[NSString class]]) { + if ([dateString length] == [DateParserDateNoTimestampFormat length]) { + NSMutableString *mutableRemoteValue = [dateString mutableCopy]; + [mutableRemoteValue appendString:DateParserTimestamp]; + dateString = [mutableRemoteValue copy]; + } + + // Convert NSDate description to NSDate + // Current date: 2009-10-09 00:00:00 + // Will become: 2009-10-09T00:00:00 + // Unit test L + if ([dateString length] == [DateParserDescriptionDate length]) { + NSString *spaceString = [dateString substringWithRange:NSMakeRange(10, 1)]; + if ([spaceString isEqualToString:@" "]) { + dateString = [dateString stringByReplacingCharactersInRange:NSMakeRange(10, 1) withString:@"T"]; + } + } + + const char *originalString = [dateString cStringUsingEncoding:NSUTF8StringEncoding]; + size_t originalLength = strlen(originalString); + if (originalLength == 0) { + return nil; + } + + char currentString[25] = ""; + BOOL hasTimezone = NO; + BOOL hasCentiseconds = NO; + BOOL hasMiliseconds = NO; + BOOL hasMicroseconds = NO; + + // ---- + // In general lines, if a Z is found, then the Z is removed since all dates operate + // in GMT as a base, unless they have timezone, and Z is the GMT indicator. + // + // If +00:00 or any number after + is found, then it means that the date has a timezone. + // This means that `hasTimezone` will have to be set to YES, and since all timezones go to + // the end of the date, then they will be parsed at the end of the process and appended back + // to the parsed date. + // + // If after the date theres `.` and a number `2014-03-30T09:13:00.XXX` the `XXX` is the milisecond + // then `hasMiliseconds` will be set to YES. The same goes for `XX` decisecond (hasCentiseconds set to YES). + // and microseconds `XXXXXX` (hasMicroseconds set yo YES). + // + // If your date format is not supported, then you'll get "Signal Sigabrt". Just ask your format to be included. + // ---- + + // Copy all the date excluding the Z. + // Current date: 2014-03-30T09:13:00Z + // Will become: 2014-03-30T09:13:00 + // Unit test H + if (originalLength == 20 && originalString[originalLength - 1] == 'Z') { + strncpy(currentString, originalString, originalLength - 1); + } + + // Copy all the date excluding the timezone also set `hasTimezone` to YES. + // Current date: 2014-01-01T00:00:00+00:00 + // Will become: 2014-01-01T00:00:00 + // Unit test B and C + else if (originalLength == 25 && originalString[22] == ':') { + strncpy(currentString, originalString, 19); + hasTimezone = YES; + } + + // Copy all the date excluding the miliseconds and the Z. + // Current date: 2014-03-30T09:13:00.000Z + // Will become: 2014-03-30T09:13:00 + // Unit test G + else if (originalLength == 24 && originalString[originalLength - 1] == 'Z') { + strncpy(currentString, originalString, 19); + hasMiliseconds = YES; + } + + // Copy all the date excluding the miliseconds and the timezone also set `hasTimezone` to YES. + // Current date: 2015-06-23T12:40:08.000+02:00 + // Will become: 2015-06-23T12:40:08 + // Unit test A + else if (originalLength == 29 && originalString[26] == ':') { + strncpy(currentString, originalString, 19); + hasTimezone = YES; + hasMiliseconds = YES; + } + + // Copy all the date excluding the microseconds and the timezone also set `hasTimezone` to YES. + // Current date: 2015-08-23T09:29:30.007450+00:00 + // Will become: 2015-08-23T09:29:30 + // Unit test D + else if (originalLength == 32 && originalString[29] == ':') { + strncpy(currentString, originalString, 19); + hasTimezone = YES; + hasMicroseconds = YES; + } + + // Copy all the date excluding the microseconds and the timezone. + // Current date: 2015-09-10T13:47:21.116+0000 + // Will become: 2015-09-10T13:47:21 + // Unit test E + else if (originalLength == 28 && originalString[23] == '+') { + strncpy(currentString, originalString, 19); + } + + // Copy all the date excluding the microseconds and the Z. + // Current date: 2015-09-10T00:00:00.184968Z + // Will become: 2015-09-10T00:00:00 + // Unit test F + else if (originalString[19] == '.' && originalString[originalLength - 1] == 'Z') { + strncpy(currentString, originalString, 19); + } + + // Copy all the date excluding the miliseconds. + // Current date: 2016-01-09T00:00:00.00 + // Will become: 2016-01-09T00:00:00 + // Unit test J + else if (originalLength == 22 && originalString[19] == '.') { + strncpy(currentString, originalString, 19); + hasCentiseconds = YES; + } + + // Poorly formatted timezone + else { + strncpy(currentString, originalString, originalLength > 24 ? 24 : originalLength); + } + + // Timezone + size_t currentLength = strlen(currentString); + if (hasTimezone) { + // Add the first part of the removed timezone to the end of the string. + // Orignal date: 2015-06-23T14:40:08.000+02:00 + // Current date: 2015-06-23T14:40:08 + // Will become: 2015-06-23T14:40:08+02 + strncpy(currentString + currentLength, originalString + originalLength - 6, 3); + + // Add the second part of the removed timezone to the end of the string. + // Original date: 2015-06-23T14:40:08.000+02:00 + // Current date: 2015-06-23T14:40:08+02 + // Will become: 2015-06-23T14:40:08+0200 + strncpy(currentString + currentLength + 3, originalString + originalLength - 2, 2); + } else { + // Add GMT timezone to the end of the string + // Current date: 2015-09-10T00:00:00 + // Will become: 2015-09-10T00:00:00+0000 + strncpy(currentString + currentLength, "+0000", 5); + } + + // Add null terminator + currentString[sizeof(currentString) - 1] = 0; + + // Parse the formatted date using `strptime`. + // %F: Equivalent to %Y-%m-%d, the ISO 8601 date format + // T: The date, time separator + // %T: Equivalent to %H:%M:%S + // %z: An RFC-822/ISO 8601 standard timezone specification + struct tm tm; + if (strptime(currentString, "%FT%T%z", &tm) == NULL) { + return nil; + } + + time_t timeStruct = mktime(&tm); + double time = (double)timeStruct; + + if (hasCentiseconds || hasMiliseconds || hasMicroseconds) { + NSString *trimmedDate = [dateString substringFromIndex:@"2015-09-10T00:00:00.".length]; + + if (hasCentiseconds) { + NSString *centisecondsString = [trimmedDate substringToIndex:@"00".length]; + double centiseconds = centisecondsString.doubleValue / 100.0; + time += centiseconds; + } + + if (hasMiliseconds) { + NSString *milisecondsString = [trimmedDate substringToIndex:@"000".length]; + double miliseconds = milisecondsString.doubleValue / 1000.0; + time += miliseconds; + } + + if (hasMicroseconds) { + NSString *microsecondsString = [trimmedDate substringToIndex:@"000000".length]; + double microseconds = microsecondsString.doubleValue / 1000000.0; + time += microseconds; + } + } + + return [NSDate dateWithTimeIntervalSince1970:time]; + } + + NSAssert1(NO, @"Failed to parse date: %@", dateString); + return nil; +} + ++ (NSDate *)dateFromUnixTimestampNumber:(NSNumber *)unixTimestamp { + return [self dateFromUnixTimestampString:[unixTimestamp stringValue]]; +} + ++ (NSDate *)dateFromUnixTimestampString:(NSString *)unixTimestamp { + NSString *parsedString = unixTimestamp; + + NSString *validUnixTimestamp = @"1441843200"; + NSInteger validLength = [validUnixTimestamp length]; + if ([unixTimestamp length] > validLength) { + parsedString = [unixTimestamp substringToIndex:validLength]; + } + + NSNumberFormatter *numberFormatter = [NSNumberFormatter new]; + numberFormatter.numberStyle = NSNumberFormatterDecimalStyle; + NSNumber *unixTimestampNumber = [numberFormatter numberFromString:parsedString]; + NSDate *date = [[NSDate alloc] initWithTimeIntervalSince1970:unixTimestampNumber.doubleValue]; + + return date; +} + +@end + +@implementation NSString (Parser) + +- (DateType)dateType { + if ([self containsString:@"-"]) { + return iso8601; + } else { + return unixTimestamp; + } +} + +@end diff --git a/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.h b/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.h new file mode 100755 index 00000000..1beaec27 --- /dev/null +++ b/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.h @@ -0,0 +1,42 @@ +@import CoreData; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const SyncDefaultLocalPrimaryKey = @"id"; +static NSString * const SyncDefaultLocalCompatiblePrimaryKey = @"remoteID"; + +static NSString * const SyncDefaultRemotePrimaryKey = @"id"; + +static NSString * const SyncCustomLocalPrimaryKey = @"hyper.isPrimaryKey"; +static NSString * const SyncCustomLocalPrimaryKeyValue = @"YES"; +static NSString * const SyncCustomLocalPrimaryKeyAlternativeValue = @"true"; + +static NSString * const SyncCustomRemoteKey = @"hyper.remoteKey"; + +@interface NSEntityDescription (SyncPrimaryKey) + +/** + Returns the Core Data attribute used as the primary key. By default it will look for the attribute named `id`. + You can mark any attribute as primary key by adding `hyper.isPrimaryKey` and the value `YES` to the Core Data model userInfo. + + @return The attribute description that represents the primary key. + */ +- (NSAttributeDescription *)sync_primaryKeyAttribute; + +/** + Returns the local primary key for the entity. + + @return The name of the attribute that represents the local primary key;. + */ +- (NSString *)sync_localPrimaryKey; + +/** + Returns the remote primary key for the entity. + + @return The name of the attribute that represents the remote primary key. + */ +- (NSString *)sync_remotePrimaryKey; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.m b/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.m new file mode 100755 index 00000000..7b965d69 --- /dev/null +++ b/Source/NSEntityDescription-SyncPrimaryKey/NSEntityDescription+SyncPrimaryKey.m @@ -0,0 +1,52 @@ +#import "NSEntityDescription+SyncPrimaryKey.h" + +#import "NSString+SyncInflections.h" + +@implementation NSEntityDescription (SyncPrimaryKey) + +- (nonnull NSAttributeDescription *)sync_primaryKeyAttribute { + __block NSAttributeDescription *primaryKeyAttribute; + + [self.propertiesByName enumerateKeysAndObjectsUsingBlock:^(NSString *key, + NSAttributeDescription *attributeDescription, + BOOL *stop) { + NSString *isPrimaryKey = attributeDescription.userInfo[SyncCustomLocalPrimaryKey]; + BOOL hasCustomPrimaryKey = (isPrimaryKey && + ([isPrimaryKey isEqualToString:SyncCustomLocalPrimaryKeyValue] || [isPrimaryKey isEqualToString:SyncCustomLocalPrimaryKeyAlternativeValue]) ); + if (hasCustomPrimaryKey) { + primaryKeyAttribute = attributeDescription; + *stop = YES; + } + + if ([key isEqualToString:SyncDefaultLocalPrimaryKey] || [key isEqualToString:SyncDefaultLocalCompatiblePrimaryKey]) { + primaryKeyAttribute = attributeDescription; + } + }]; + + return primaryKeyAttribute; +} + +- (nonnull NSString *)sync_localPrimaryKey { + NSAttributeDescription *primaryAttribute = [self sync_primaryKeyAttribute]; + NSString *localKey = primaryAttribute.name; + + return localKey; +} + +- (nonnull NSString *)sync_remotePrimaryKey { + NSAttributeDescription *primaryKeyAttribute = [self sync_primaryKeyAttribute]; + NSString *remoteKey = primaryKeyAttribute.userInfo[SyncCustomRemoteKey]; + + if (!remoteKey) { + if ([primaryKeyAttribute.name isEqualToString:SyncDefaultLocalPrimaryKey] || [primaryKeyAttribute.name isEqualToString:SyncDefaultLocalCompatiblePrimaryKey]) { + remoteKey = SyncDefaultRemotePrimaryKey; + } else { + remoteKey = [primaryKeyAttribute.name hyp_snakeCase]; + } + + } + + return remoteKey; +} + +@end diff --git a/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.h b/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.h new file mode 100755 index 00000000..6421e31e --- /dev/null +++ b/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.h @@ -0,0 +1,46 @@ +@import CoreData; + +#import "SyncPropertyMapper.h" + +static NSString * const SyncPropertyMapperDestroyKey = @"destroy"; +static NSString * const SyncPropertyMapperCustomValueTransformerKey = @"hyper.valueTransformer"; +static NSString * const SyncPropertyMapperCustomRemoteKey = @"hyper.remoteKey"; +static NSString * const SyncPropertyMapperNonExportableKey = @"hyper.nonExportable"; + +/** + Internal helpers, not meant to be included in the public APIs. + */ +@interface NSManagedObject (SyncPropertyMapperHelpers) + +- (id)valueForAttributeDescription:(NSAttributeDescription *)attributeDescription + dateFormatter:(NSDateFormatter *)dateFormatter + relationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +- (NSAttributeDescription *)attributeDescriptionForRemoteKey:(NSString *)remoteKey; + +- (NSAttributeDescription *)attributeDescriptionForRemoteKey:(NSString *)remoteKey + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType; + +- (NSArray *)attributeDescriptionsForRemoteKeyPath:(NSString *)key; + +- (id)valueForAttributeDescription:(id)attributeDescription + usingRemoteValue:(id)removeValue; + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription; + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + inflectionType:(SyncPropertyMapperInflectionType)inflectionType; + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType + inflectionType:(SyncPropertyMapperInflectionType)inflectionType; + ++ (NSArray *)reservedAttributes; + +- (NSString *)prefixedAttribute:(NSString *)attribute + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType; + +@end diff --git a/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.m b/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.m new file mode 100755 index 00000000..12a0f949 --- /dev/null +++ b/Source/NSManagedObject-SyncPropertyMapper/NSManagedObject+SyncPropertyMapperHelpers.m @@ -0,0 +1,286 @@ +#import "NSManagedObject+SyncPropertyMapperHelpers.h" + +#import "SyncPropertyMapper.h" +#import "NSString+SyncInflections.h" +#import "NSEntityDescription+SyncPrimaryKey.h" +#import "NSDate+SyncPropertyMapper.h" + +@implementation NSManagedObject (SyncPropertyMapperHelpers) + +- (id)valueForAttributeDescription:(NSAttributeDescription *)attributeDescription + dateFormatter:(NSDateFormatter *)dateFormatter + relationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + id value; + if (attributeDescription.attributeType != NSTransformableAttributeType) { + value = [self valueForKey:attributeDescription.name]; + BOOL nilOrNullValue = (!value || + [value isKindOfClass:[NSNull class]]); + NSString *customTransformerName = attributeDescription.userInfo[SyncPropertyMapperCustomValueTransformerKey]; + if (nilOrNullValue) { + value = [NSNull null]; + } else if ([value isKindOfClass:[NSDate class]]) { + value = [dateFormatter stringFromDate:value]; + } else if (customTransformerName) { + NSValueTransformer *transformer = [NSValueTransformer valueTransformerForName:customTransformerName]; + if (transformer) { + value = [transformer reverseTransformedValue:value]; + } + } + } + + return value; +} + +- (NSAttributeDescription *)attributeDescriptionForRemoteKey:(NSString *)remoteKey { + return [self attributeDescriptionForRemoteKey:remoteKey usingInflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; +} + +- (NSAttributeDescription *)attributeDescriptionForRemoteKey:(NSString *)remoteKey + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + __block NSAttributeDescription *foundAttributeDescription; + + [self.entity.properties enumerateObjectsUsingBlock:^(id propertyDescription, NSUInteger idx, BOOL *stop) { + if ([propertyDescription isKindOfClass:[NSAttributeDescription class]]) { + NSAttributeDescription *attributeDescription = (NSAttributeDescription *)propertyDescription; + + NSDictionary *userInfo = [self.entity.propertiesByName[attributeDescription.name] userInfo]; + NSString *customRemoteKey = userInfo[SyncPropertyMapperCustomRemoteKey]; + BOOL currentAttributeHasTheSameRemoteKey = (customRemoteKey.length > 0 && [customRemoteKey isEqualToString:remoteKey]); + if (currentAttributeHasTheSameRemoteKey) { + foundAttributeDescription = attributeDescription; + *stop = YES; + } + + NSString *customRootRemoteKey = [[customRemoteKey componentsSeparatedByString:@"."] firstObject]; + BOOL currentAttributeHasTheSameRootRemoteKey = (customRootRemoteKey.length > 0 && [customRootRemoteKey isEqualToString:remoteKey]); + if (currentAttributeHasTheSameRootRemoteKey) { + foundAttributeDescription = attributeDescription; + *stop = YES; + } + + if ([attributeDescription.name isEqualToString:remoteKey]) { + foundAttributeDescription = attributeDescription; + *stop = YES; + } + + NSString *localKey = [remoteKey hyp_camelCase]; + BOOL isReservedKey = ([[NSManagedObject reservedAttributes] containsObject:remoteKey]); + if (isReservedKey) { + NSString *prefixedRemoteKey = [self prefixedAttribute:remoteKey usingInflectionType:inflectionType]; + localKey = [prefixedRemoteKey hyp_camelCase]; + } + + if ([attributeDescription.name isEqualToString:localKey]) { + foundAttributeDescription = attributeDescription; + *stop = YES; + } + } + }]; + + if (!foundAttributeDescription) { + [self.entity.properties enumerateObjectsUsingBlock:^(id propertyDescription, NSUInteger idx, BOOL *stop) { + if ([propertyDescription isKindOfClass:[NSAttributeDescription class]]) { + NSAttributeDescription *attributeDescription = (NSAttributeDescription *)propertyDescription; + + if ([remoteKey isEqualToString:SyncDefaultRemotePrimaryKey] && + ([attributeDescription.name isEqualToString:SyncDefaultLocalPrimaryKey] || [attributeDescription.name isEqualToString:SyncDefaultLocalCompatiblePrimaryKey])) { + foundAttributeDescription = self.entity.propertiesByName[attributeDescription.name]; + } + + if (foundAttributeDescription) { + *stop = YES; + } + } + }]; + } + + return foundAttributeDescription; +} + +- (NSArray *)attributeDescriptionsForRemoteKeyPath:(NSString *)remoteKey { + __block NSMutableArray *foundAttributeDescriptions = [NSMutableArray array]; + + [self.entity.properties enumerateObjectsUsingBlock:^(id propertyDescription, NSUInteger idx, BOOL *stop) { + if ([propertyDescription isKindOfClass:[NSAttributeDescription class]]) { + NSAttributeDescription *attributeDescription = (NSAttributeDescription *)propertyDescription; + + NSDictionary *userInfo = [self.entity.propertiesByName[attributeDescription.name] userInfo]; + NSString *customRemoteKeyPath = userInfo[SyncPropertyMapperCustomRemoteKey]; + NSString *customRootRemoteKey = [[customRemoteKeyPath componentsSeparatedByString:@"."] firstObject]; + NSString *rootRemoteKey = [[remoteKey componentsSeparatedByString:@"."] firstObject]; + BOOL currentAttributeHasTheSameRootRemoteKey = (customRootRemoteKey.length > 0 && [customRootRemoteKey isEqualToString:rootRemoteKey]); + if (currentAttributeHasTheSameRootRemoteKey) { + [foundAttributeDescriptions addObject:attributeDescription]; + } + } + }]; + + return foundAttributeDescriptions; +} + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription { + return [self remoteKeyForAttributeDescription:attributeDescription usingRelationshipType:SyncPropertyMapperRelationshipTypeNested inflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; +} + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + inflectionType:(SyncPropertyMapperInflectionType)inflectionType { + return [self remoteKeyForAttributeDescription:attributeDescription usingRelationshipType:SyncPropertyMapperRelationshipTypeNested inflectionType:inflectionType]; +} + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self remoteKeyForAttributeDescription:attributeDescription usingRelationshipType:relationshipType inflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; +} + +- (NSString *)remoteKeyForAttributeDescription:(NSAttributeDescription *)attributeDescription + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType + inflectionType:(SyncPropertyMapperInflectionType)inflectionType { + NSDictionary *userInfo = attributeDescription.userInfo; + NSString *localKey = attributeDescription.name; + NSString *remoteKey; + + NSString *customRemoteKey = userInfo[SyncPropertyMapperCustomRemoteKey]; + if (customRemoteKey) { + remoteKey = customRemoteKey; + } else if ([localKey isEqualToString:SyncDefaultLocalPrimaryKey] || [localKey isEqualToString:SyncDefaultLocalCompatiblePrimaryKey]) { + remoteKey = SyncDefaultRemotePrimaryKey; + } else if ([localKey isEqualToString:SyncPropertyMapperDestroyKey] && + relationshipType == SyncPropertyMapperRelationshipTypeNested) { + remoteKey = [NSString stringWithFormat:@"_%@", SyncPropertyMapperDestroyKey]; + } else { + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: + remoteKey = [localKey hyp_snakeCase]; + break; + case SyncPropertyMapperInflectionTypeCamelCase: + remoteKey = localKey; + break; + } + } + + BOOL isReservedKey = ([[self reservedKeysUsingInflectionType:inflectionType] containsObject:remoteKey]); + if (isReservedKey) { + NSMutableString *prefixedKey = [remoteKey mutableCopy]; + [prefixedKey replaceOccurrencesOfString:[self remotePrefixUsingInflectionType:inflectionType] + withString:@"" + options:NSCaseInsensitiveSearch + range:NSMakeRange(0, prefixedKey.length)]; + remoteKey = [prefixedKey copy]; + if (inflectionType == SyncPropertyMapperInflectionTypeCamelCase) { + remoteKey = [remoteKey hyp_camelCase]; + } + } + + return remoteKey; +} + +- (id)valueForAttributeDescription:(NSAttributeDescription *)attributeDescription + usingRemoteValue:(id)remoteValue { + id value; + + Class attributedClass = NSClassFromString([attributeDescription attributeValueClassName]); + + if ([remoteValue isKindOfClass:attributedClass]) { + value = remoteValue; + } + + NSString *customTransformerName = attributeDescription.userInfo[SyncPropertyMapperCustomValueTransformerKey]; + if (customTransformerName) { + NSValueTransformer *transformer = [NSValueTransformer valueTransformerForName:customTransformerName]; + if (transformer) { + value = [transformer transformedValue:remoteValue]; + } + } + + BOOL stringValueAndNumberAttribute = ([remoteValue isKindOfClass:[NSString class]] && + attributedClass == [NSNumber class]); + + BOOL numberValueAndStringAttribute = ([remoteValue isKindOfClass:[NSNumber class]] && + attributedClass == [NSString class]); + + BOOL stringValueAndDateAttribute = ([remoteValue isKindOfClass:[NSString class]] && + attributedClass == [NSDate class]); + + BOOL numberValueAndDateAttribute = ([remoteValue isKindOfClass:[NSNumber class]] && + attributedClass == [NSDate class]); + + BOOL dataAttribute = (attributedClass == [NSData class]); + + BOOL numberValueAndDecimalAttribute = ([remoteValue isKindOfClass:[NSNumber class]] && + attributedClass == [NSDecimalNumber class]); + + BOOL stringValueAndDecimalAttribute = ([remoteValue isKindOfClass:[NSString class]] && + attributedClass == [NSDecimalNumber class]); + + BOOL transformableAttribute = (!attributedClass && [attributeDescription valueTransformerName] && value == nil); + + if (stringValueAndNumberAttribute) { + NSNumberFormatter *formatter = [NSNumberFormatter new]; + formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US"]; + value = [formatter numberFromString:remoteValue]; + } else if (numberValueAndStringAttribute) { + value = [NSString stringWithFormat:@"%@", remoteValue]; + } else if (stringValueAndDateAttribute) { + value = [NSDate dateFromDateString:remoteValue]; + } else if (numberValueAndDateAttribute) { + value = [NSDate dateFromUnixTimestampNumber:remoteValue]; + } else if (dataAttribute) { + value = [NSKeyedArchiver archivedDataWithRootObject:remoteValue]; + } else if (numberValueAndDecimalAttribute) { + NSNumber *number = (NSNumber *)remoteValue; + value = [NSDecimalNumber decimalNumberWithDecimal:[number decimalValue]]; + } else if (stringValueAndDecimalAttribute) { + value = [NSDecimalNumber decimalNumberWithString:remoteValue]; + } else if (transformableAttribute) { + NSValueTransformer *transformer = [NSValueTransformer valueTransformerForName:[attributeDescription valueTransformerName]]; + if (transformer) { + id newValue = [transformer transformedValue:remoteValue]; + if (newValue) { + value = newValue; + } + } + } + + return value; +} + +- (NSString *)remotePrefixUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: + return [NSString stringWithFormat:@"%@_", [self.entity.name hyp_snakeCase]]; + break; + case SyncPropertyMapperInflectionTypeCamelCase: + return [self.entity.name hyp_camelCase]; + break; + } +} + +- (NSString *)prefixedAttribute:(NSString *)attribute usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + NSString *remotePrefix = [self remotePrefixUsingInflectionType:inflectionType]; + + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: { + return [NSString stringWithFormat:@"%@%@", remotePrefix, attribute]; + } break; + case SyncPropertyMapperInflectionTypeCamelCase: { + return [NSString stringWithFormat:@"%@%@", remotePrefix, [attribute capitalizedString]]; + } break; + } +} + +- (NSArray *)reservedKeysUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + NSMutableArray *keys = [NSMutableArray new]; + NSArray *reservedAttributes = [NSManagedObject reservedAttributes]; + + for (NSString *attribute in reservedAttributes) { + [keys addObject:[self prefixedAttribute:attribute usingInflectionType:inflectionType]]; + } + + return keys; +} + ++ (NSArray *)reservedAttributes { + return @[@"type", @"description", @"signed"]; +} + +@end diff --git a/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.h b/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.h new file mode 100755 index 00000000..ea88064a --- /dev/null +++ b/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.h @@ -0,0 +1,149 @@ +@import CoreData; +@import Foundation; + +#import "NSDate+SyncPropertyMapper.h" +#import "NSEntityDescription+SyncPrimaryKey.h" +#import "NSString+SyncInflections.h" + +FOUNDATION_EXPORT double SyncPropertyMapperVersionNumber; +FOUNDATION_EXPORT const unsigned char SyncPropertyMapperVersionString[]; + +NS_ASSUME_NONNULL_BEGIN + +/** + The relationship type used to export the NSManagedObject as JSON. + + - SyncPropertyMapperRelationshipTypeNone: Skip all relationships. + - SyncPropertyMapperRelationshipTypeArray: Normal JSON representation of relationships. + - SyncPropertyMapperRelationshipTypeNested: Uses Ruby on Rails's accepts_nested_attributes_for notation to represent relationships. + */ +typedef NS_ENUM(NSInteger, SyncPropertyMapperRelationshipType) { + SyncPropertyMapperRelationshipTypeNone = 0, + SyncPropertyMapperRelationshipTypeArray, + SyncPropertyMapperRelationshipTypeNested +}; + +/** + The relationship type used to export the NSManagedObject as JSON. + + - SyncPropertyMapperRelationshipTypeNone: Skip all relationships. + - SyncPropertyMapperRelationshipTypeArray: Normal JSON representation of relationships. + - SyncPropertyMapperRelationshipTypeNested: Uses Ruby on Rails's accepts_nested_attributes_for notation to represent relationships. + */ +typedef NS_ENUM(NSInteger, SyncPropertyMapperInflectionType) { + SyncPropertyMapperInflectionTypeSnakeCase = 0, + SyncPropertyMapperInflectionTypeCamelCase +}; + +/** + Collection of helper methods to facilitate mapping JSON to NSManagedObject. + */ +@interface NSManagedObject (SyncPropertyMapper) + +/** + Fills the @c NSManagedObject with the contents of the dictionary using a convention-over-configuration paradigm mapping the Core Data attributes to their conterparts in JSON using snake_case. + + @param dictionary The JSON dictionary to be used to fill the values of your @c NSManagedObject. + */ +- (void)hyp_fillWithDictionary:(NSDictionary *)dictionary; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Includes relationships to other models using Ruby on Rail's nested attributes model. + @c NSDate objects will be stringified to the ISO-8601 standard. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionary; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Includes relationships to other models using Ruby on Rail's nested attributes model. + @c NSDate objects will be stringified to the ISO-8601 standard. + + @param inflectionType The type used to export the dictionary, can be camelCase or snakeCase. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models. + @c NSDate objects will be stringified to the ISO-8601 standard. + + @param relationshipType It indicates wheter the result dictionary should include no relationships, nested attributes or normal attributes. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryUsingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models. + @c NSDate objects will be stringified to the ISO-8601 standard. + + @param inflectionType The type used to export the dictionary, can be camelCase or snakeCase. + @param relationshipType It indicates wheter the result dictionary should include no relationships, nested attributes or normal attributes. + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Includes relationships to other models using Ruby on Rail's nested attributes model. + + @param dateFormatter A custom date formatter that turns @c NSDate objects into NSString objects. Do not pass @c nil, instead use the @c hyp_dictionary method. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models using Ruby on Rail's nested attributes model. + + @param dateFormatter A custom date formatter that turns @c NSDate objects into @c NSString objects. Do not pass nil, instead use the 'hyp_dictionary' method. + @param relationshipType It indicates wheter the result dictionary should include no relationships, nested attributes or normal attributes. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models using Ruby on Rail's nested attributes model. + + @param dateFormatter A custom date formatter that turns @c NSDate objects into @c NSString objects. Do not pass nil, instead use the 'hyp_dictionary' method. + @param inflectionType The type used to export the dictionary, can be camelCase or snakeCase. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models using Ruby on Rail's nested attributes model. + + @param dateFormatter A custom date formatter that turns @c NSDate objects into @c NSString objects. Do not pass nil, instead use the 'hyp_dictionary' method. + @param inflectionType The type used to export the dictionary, can be camelCase or snakeCase. + @param relationshipType It indicates wheter the result dictionary should include no relationships, nested attributes or normal attributes. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +/** + Creates a @c NSDictionary of values based on the @c NSManagedObject subclass that can be serialized by @c NSJSONSerialization. Could include relationships to other models using Ruby on Rail's nested attributes model. + + @param dateFormatter A custom date formatter that turns @c NSDate objects into @c NSString objects. Do not pass nil, instead use the @c hyp_dictionary method. + @param parent The parent of the managed object. + @param relationshipType It indicates wheter the result dictionary should include no relationships, nested attributes or normal attributes. + + @return The JSON representation of the @c NSManagedObject in the form of a @c NSDictionary. + */ +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + parent:( NSManagedObject * _Nullable)parent + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.m b/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.m new file mode 100755 index 00000000..7ef4892c --- /dev/null +++ b/Source/NSManagedObject-SyncPropertyMapper/SyncPropertyMapper.m @@ -0,0 +1,291 @@ +#import "SyncPropertyMapper.h" + +#import "NSString+SyncInflections.h" +#import "NSManagedObject+SyncPropertyMapperHelpers.h" +#import "NSDate+SyncPropertyMapper.h" + +static NSString * const SyncPropertyMapperNestedAttributesKey = @"attributes"; + +@implementation NSManagedObject (SyncPropertyMapper) + +#pragma mark - Public methods + +- (void)hyp_fillWithDictionary:(NSDictionary *)dictionary { + for (__strong NSString *key in dictionary) { + id value = [dictionary objectForKey:key]; + + NSAttributeDescription *attributeDescription = [self attributeDescriptionForRemoteKey:key]; + if (attributeDescription) { + BOOL valueExists = (value && ![value isKindOfClass:[NSNull class]]); + if (valueExists && [value isKindOfClass:[NSDictionary class]] && attributeDescription.attributeType != NSBinaryDataAttributeType) { + NSString *remoteKey = [self remoteKeyForAttributeDescription:attributeDescription + inflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; + BOOL hasCustomKeyPath = remoteKey && [remoteKey rangeOfString:@"."].location != NSNotFound; + if (hasCustomKeyPath) { + NSArray *keyPathAttributeDescriptions = [self attributeDescriptionsForRemoteKeyPath:remoteKey]; + for (NSAttributeDescription *keyPathAttributeDescription in keyPathAttributeDescriptions) { + NSString *remoteKey = [self remoteKeyForAttributeDescription:keyPathAttributeDescription + inflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; + NSString *localKey = keyPathAttributeDescription.name; + [self hyp_setDictionaryValue:[dictionary valueForKeyPath:remoteKey] + forKey:localKey + attributeDescription:keyPathAttributeDescription]; + } + } + } else { + NSString *localKey = attributeDescription.name; + [self hyp_setDictionaryValue:value + forKey:localKey + attributeDescription:attributeDescription]; + } + } + } +} + +- (void)hyp_setDictionaryValue:(id)value forKey:(NSString *)key + attributeDescription:(NSAttributeDescription *)attributeDescription { + BOOL valueExists = (value && ![value isKindOfClass:[NSNull class]]); + if (valueExists) { + id processedValue = [self valueForAttributeDescription:attributeDescription + usingRemoteValue:value]; + + BOOL valueHasChanged = (![[self valueForKey:key] isEqual:processedValue]); + if (valueHasChanged) { + [self setValue:processedValue forKey:key]; + } + } else if ([self valueForKey:key]) { + [self setValue:nil forKey:key]; + } +} + +- (NSDictionary *)hyp_dictionary { + return [self hyp_dictionaryUsingInflectionType:SyncPropertyMapperInflectionTypeSnakeCase]; +} + +- (NSDictionary *)hyp_dictionaryUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + return [self hyp_dictionaryWithDateFormatter:[self defaultDateFormatter] + parent:nil + usingInflectionType:inflectionType + andRelationshipType:SyncPropertyMapperRelationshipTypeNested]; +} + +- (NSDictionary *)hyp_dictionaryUsinginflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:[self defaultDateFormatter] + parent:nil + usingInflectionType:inflectionType + andRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryUsingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:[self defaultDateFormatter] + usingRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryUsingInflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:[self defaultDateFormatter] + parent:nil + usingInflectionType:inflectionType + andRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter { + return [self hyp_dictionaryWithDateFormatter:dateFormatter + parent:nil + usingRelationshipType:SyncPropertyMapperRelationshipTypeNested]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:dateFormatter + parent:nil + usingRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType { + return [self hyp_dictionaryWithDateFormatter:dateFormatter + parent:nil + usingInflectionType:inflectionType + andRelationshipType:SyncPropertyMapperRelationshipTypeNested]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:dateFormatter + parent:nil + usingInflectionType:inflectionType + andRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + parent:( NSManagedObject * _Nullable )parent + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + return [self hyp_dictionaryWithDateFormatter:dateFormatter + parent:parent + usingInflectionType:SyncPropertyMapperInflectionTypeSnakeCase + andRelationshipType:relationshipType]; +} + +- (NSDictionary *)hyp_dictionaryWithDateFormatter:(NSDateFormatter *)dateFormatter + parent:( NSManagedObject * _Nullable )parent + usingInflectionType:(SyncPropertyMapperInflectionType)inflectionType + andRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType { + NSMutableDictionary *managedObjectAttributes = [NSMutableDictionary new]; + + for (id propertyDescription in self.entity.properties) { + if ([propertyDescription isKindOfClass:[NSAttributeDescription class]]) { + NSDictionary *userInfo = [propertyDescription userInfo]; + NSString *nonExportableKey = userInfo[SyncPropertyMapperNonExportableKey]; + BOOL shouldExportAttribute = (nonExportableKey == nil); + if (shouldExportAttribute) { + id value = [self valueForAttributeDescription:propertyDescription + dateFormatter:dateFormatter + relationshipType:relationshipType]; + if (value) { + NSString *remoteKey = [self remoteKeyForAttributeDescription:propertyDescription + usingRelationshipType:relationshipType + inflectionType:inflectionType]; + managedObjectAttributes[remoteKey] = value; + } + } + } else if ([propertyDescription isKindOfClass:[NSRelationshipDescription class]] && + relationshipType != SyncPropertyMapperRelationshipTypeNone) { + NSRelationshipDescription *relationshipDescription = (NSRelationshipDescription *)propertyDescription; + NSDictionary *userInfo = relationshipDescription.userInfo; + NSString *nonExportableKey = userInfo[SyncPropertyMapperNonExportableKey]; + if (nonExportableKey == nil) { + BOOL isValidRelationship = !(parent && [parent.entity isEqual:relationshipDescription.destinationEntity] && !relationshipDescription.isToMany); + if (isValidRelationship) { + NSString *relationshipName = [relationshipDescription name]; + id relationships = [self valueForKey:relationshipName]; + if (relationships) { + BOOL isToOneRelationship = (![relationships isKindOfClass:[NSSet class]] && ![relationships isKindOfClass:[NSOrderedSet class]]); + if (isToOneRelationship) { + NSDictionary *attributesForToOneRelationship = [self attributesForToOneRelationship:relationships + relationshipName:relationshipName + usingRelationshipType:relationshipType + parent:self + dateFormatter:dateFormatter + inflectionType:inflectionType]; + [managedObjectAttributes addEntriesFromDictionary:attributesForToOneRelationship]; + } else { + NSDictionary *attributesForToManyRelationship = [self attributesForToManyRelationship:relationships + relationshipName:relationshipName + usingRelationshipType:relationshipType + parent:self + dateFormatter:dateFormatter + inflectionType:inflectionType]; + [managedObjectAttributes addEntriesFromDictionary:attributesForToManyRelationship]; + } + } + } + } + } + } + + return [managedObjectAttributes copy]; +} + +- (NSDictionary *)attributesForToOneRelationship:(NSManagedObject *)relationship + relationshipName:(NSString *)relationshipName + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType + parent:(NSManagedObject *)parent + dateFormatter:(NSDateFormatter *)dateFormatter + inflectionType:(SyncPropertyMapperInflectionType)inflectionType { + + NSMutableDictionary *attributesForToOneRelationship = [NSMutableDictionary new]; + NSDictionary *attributes = [relationship hyp_dictionaryWithDateFormatter:dateFormatter + parent:parent + usingInflectionType:inflectionType + andRelationshipType:relationshipType]; + if (attributes.count > 0) { + NSString *key; + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: + key = [relationshipName hyp_snakeCase]; + break; + case SyncPropertyMapperInflectionTypeCamelCase: + key = relationshipName; + break; + } + if (relationshipType == SyncPropertyMapperRelationshipTypeNested) { + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: + key = [NSString stringWithFormat:@"%@_%@", key, SyncPropertyMapperNestedAttributesKey]; + break; + case SyncPropertyMapperInflectionTypeCamelCase: + key = [NSString stringWithFormat:@"%@%@", key, [SyncPropertyMapperNestedAttributesKey capitalizedString]]; + break; + } + } + + [attributesForToOneRelationship setValue:attributes forKey:key]; + } + + return attributesForToOneRelationship; +} + +- (NSDictionary *)attributesForToManyRelationship:(NSSet *)relationships + relationshipName:(NSString *)relationshipName + usingRelationshipType:(SyncPropertyMapperRelationshipType)relationshipType + parent:(NSManagedObject *)parent + dateFormatter:(NSDateFormatter *)dateFormatter + inflectionType:(SyncPropertyMapperInflectionType)inflectionType { + + NSMutableDictionary *attributesForToManyRelationship = [NSMutableDictionary new]; + NSUInteger relationIndex = 0; + NSMutableDictionary *relationsDictionary = [NSMutableDictionary new]; + NSMutableArray *relationsArray = [NSMutableArray new]; + for (NSManagedObject *relationship in relationships) { + NSDictionary *attributes = [relationship hyp_dictionaryWithDateFormatter:dateFormatter + parent:parent + usingInflectionType:inflectionType + andRelationshipType:relationshipType]; + if (attributes.count > 0) { + if (relationshipType == SyncPropertyMapperRelationshipTypeArray) { + [relationsArray addObject:attributes]; + } else if (relationshipType == SyncPropertyMapperRelationshipTypeNested) { + NSString *relationIndexString = [NSString stringWithFormat:@"%lu", (unsigned long)relationIndex]; + relationsDictionary[relationIndexString] = attributes; + relationIndex++; + } + } + } + + NSString *key; + switch (inflectionType) { + case SyncPropertyMapperInflectionTypeSnakeCase: { + key = [relationshipName hyp_snakeCase]; + } break; + case SyncPropertyMapperInflectionTypeCamelCase: { + key = [relationshipName hyp_camelCase]; + } break; + } + if (relationshipType == SyncPropertyMapperRelationshipTypeArray) { + [attributesForToManyRelationship setValue:relationsArray forKey:key]; + } else if (relationshipType == SyncPropertyMapperRelationshipTypeNested) { + NSString *nestedAttributesPrefix = [NSString stringWithFormat:@"%@_%@", key, SyncPropertyMapperNestedAttributesKey]; + [attributesForToManyRelationship setValue:relationsDictionary forKey:nestedAttributesPrefix]; + } + + return attributesForToManyRelationship; +} + +#pragma mark - Private + +- (NSDateFormatter *)defaultDateFormatter { + static NSDateFormatter *_dateFormatter = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _dateFormatter = [NSDateFormatter new]; + _dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + _dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ssZZZZZ"; + }); + + return _dateFormatter; +} + +@end diff --git a/Source/NSString-SyncInflections/NSString+SyncInflections.h b/Source/NSString-SyncInflections/NSString+SyncInflections.h new file mode 100755 index 00000000..cfdebef4 --- /dev/null +++ b/Source/NSString-SyncInflections/NSString+SyncInflections.h @@ -0,0 +1,9 @@ +@import Foundation; + +@interface NSString (SyncInflections) + +- (nonnull NSString *)hyp_snakeCase; + +- (nullable NSString *)hyp_camelCase; + +@end diff --git a/Source/NSString-SyncInflections/NSString+SyncInflections.m b/Source/NSString-SyncInflections/NSString+SyncInflections.m new file mode 100755 index 00000000..5d4a7f84 --- /dev/null +++ b/Source/NSString-SyncInflections/NSString+SyncInflections.m @@ -0,0 +1,208 @@ +#import "NSString+SyncInflections.h" + +typedef void (^SyncInflectionsStringStorageBlock)(void); + +@interface SyncInflectionsStringStorage : NSObject + +@property (nonatomic, strong) NSMutableDictionary *snakeCaseStorage; +@property (nonatomic, strong) NSMutableDictionary *camelCaseStorage; +@property (nonatomic, strong) dispatch_queue_t serialQueue; + +@end + +@implementation SyncInflectionsStringStorage + ++ (instancetype)sharedInstance { + static dispatch_once_t once; + static SyncInflectionsStringStorage *sharedInstance; + dispatch_once(&once, ^{ + sharedInstance = [self new]; + }); + return sharedInstance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _serialQueue = dispatch_queue_create("com.syncdb.NSString_SyncInflections.serialQueue", DISPATCH_QUEUE_SERIAL); + } + + return self; + +} + +- (NSMutableDictionary *)snakeCaseStorage { + if (!_snakeCaseStorage) { + _snakeCaseStorage = [NSMutableDictionary new]; + } + + return _snakeCaseStorage; +} + +- (NSMutableDictionary *)camelCaseStorage { + if (!_camelCaseStorage) { + _camelCaseStorage = [NSMutableDictionary new]; + } + + return _camelCaseStorage; +} + +- (void)performOnDictionary:(SyncInflectionsStringStorageBlock)block { + dispatch_sync(_serialQueue, block); + +} + +@end + +@interface NSString (PrivateInflections) + +- (BOOL)hyp_containsWord:(NSString *)word; +- (NSString *)hyp_lowerCaseFirstLetter; +- (NSString *)hyp_replaceIdentifierWithString:(NSString *)replacementString; + +@end + +@implementation NSString (SyncInflections) + +#pragma mark - Private methods + +- (nonnull NSString *)hyp_snakeCase { + SyncInflectionsStringStorage *const stringStorage = [SyncInflectionsStringStorage sharedInstance]; + __block NSString *storedResult = nil; + + [stringStorage performOnDictionary:^{ + storedResult = [[stringStorage snakeCaseStorage] objectForKey:self]; + }]; + + if (storedResult) { + return storedResult; + } else { + NSString *firstLetterLowercase = [self hyp_lowerCaseFirstLetter]; + NSString *result = [firstLetterLowercase hyp_replaceIdentifierWithString:@"_"]; + + [stringStorage performOnDictionary:^{ + [stringStorage snakeCaseStorage][self] = result; + }]; + + return result; + } +} + +- (nullable NSString *)hyp_camelCase { + SyncInflectionsStringStorage *const stringStorage = [SyncInflectionsStringStorage sharedInstance]; + __block NSString *storedResult = nil; + + [stringStorage performOnDictionary:^{ + storedResult = [[stringStorage camelCaseStorage] objectForKey:self]; + }]; + + if (storedResult) { + return storedResult; + } else { + NSString *result; + + if ([self containsString:@"_"]) { + NSString *processedString = self; + processedString = [processedString hyp_replaceIdentifierWithString:@""]; + BOOL remoteStringIsAnAcronym = ([[NSString acronyms] containsObject:[processedString lowercaseString]]); + result = (remoteStringIsAnAcronym) ? [processedString lowercaseString] : [processedString hyp_lowerCaseFirstLetter]; + } else { + result = [self hyp_lowerCaseFirstLetter]; + } + + [stringStorage performOnDictionary:^{ + [stringStorage camelCaseStorage][self] = result; + }]; + + return result; + } +} + +- (BOOL)hyp_containsWord:(NSString *)word { + BOOL found = NO; + + NSArray *components = [self componentsSeparatedByString:@"_"]; + + for (NSString *component in components) { + if ([component isEqualToString:word]) { + found = YES; + break; + } + } + + return found; +} + +- (nonnull NSString *)hyp_lowerCaseFirstLetter { + NSMutableString *mutableString = [[NSMutableString alloc] initWithString:self]; + NSString *firstLetter = [[mutableString substringToIndex:1] lowercaseString]; + [mutableString replaceCharactersInRange:NSMakeRange(0,1) + withString:firstLetter]; + + return [mutableString copy]; +} + +- (nullable NSString *)hyp_replaceIdentifierWithString:(NSString *)replacementString { + NSScanner *scanner = [NSScanner scannerWithString:self]; + scanner.caseSensitive = YES; + + NSCharacterSet *identifierSet = [NSCharacterSet characterSetWithCharactersInString:@"_- "]; + NSCharacterSet *alphanumericSet = [NSCharacterSet alphanumericCharacterSet]; + NSCharacterSet *uppercaseSet = [NSCharacterSet uppercaseLetterCharacterSet]; + + NSCharacterSet *lowercaseLettersSet = [NSCharacterSet lowercaseLetterCharacterSet]; + NSCharacterSet *decimalDigitSet = [NSCharacterSet decimalDigitCharacterSet]; + NSMutableCharacterSet *mutableLowercaseSet = [[NSMutableCharacterSet alloc] init]; + [mutableLowercaseSet formUnionWithCharacterSet:lowercaseLettersSet]; + [mutableLowercaseSet formUnionWithCharacterSet:decimalDigitSet]; + NSCharacterSet *lowercaseSet = [mutableLowercaseSet copy]; + + NSString *buffer = nil; + NSMutableString *output = [NSMutableString string]; + + while (!scanner.isAtEnd) { + BOOL isExcludedCharacter = [scanner scanCharactersFromSet:identifierSet intoString:&buffer]; + if (isExcludedCharacter) continue; + + if ([replacementString length] > 0) { + BOOL isUppercaseCharacter = [scanner scanCharactersFromSet:uppercaseSet intoString:&buffer]; + if (isUppercaseCharacter) { + for (NSString *string in [NSString acronyms]) { + BOOL containsString = ([[buffer lowercaseString] rangeOfString:string].location != NSNotFound); + if (containsString) { + if (buffer.length == string.length) { + buffer = string; + } else { + buffer = [NSString stringWithFormat:@"%@_%@", string, [[buffer lowercaseString] stringByReplacingOccurrencesOfString:string withString:@""]]; + } + break; + } + } + [output appendString:replacementString]; + [output appendString:[buffer lowercaseString]]; + } + + BOOL isLowercaseCharacter = [scanner scanCharactersFromSet:lowercaseSet intoString:&buffer]; + if (isLowercaseCharacter) { + [output appendString:[buffer lowercaseString]]; + } + } else if ([scanner scanCharactersFromSet:alphanumericSet intoString:&buffer]) { + if ([[NSString acronyms] containsObject:buffer]) { + [output appendString:[buffer uppercaseString]]; + } else { + [output appendString:[buffer capitalizedString]]; + } + } else { + output = nil; + break; + } + } + + return output; +} + ++ (nonnull NSArray *)acronyms { + return @[@"id", @"pdf", @"url", @"png", @"jpg", @"uri", @"json", @"xml"]; +} + +@end diff --git a/Source/Sync.h b/Source/Sync.h new file mode 100644 index 00000000..ea46df00 --- /dev/null +++ b/Source/Sync.h @@ -0,0 +1,10 @@ +@import Foundation; +@import CoreData; + +FOUNDATION_EXPORT double SyncVersionNumber; + +FOUNDATION_EXPORT const unsigned char SyncVersionString[]; + +#import "SyncPropertyMapper.h" +#import "NSEntityDescription+SyncPrimaryKey.h" +#import "NSManagedObject+SyncPropertyMapperHelpers.h" diff --git a/Source/Sync/NSArray+Sync.swift b/Source/Sync/NSArray+Sync.swift new file mode 100644 index 00000000..b9295edb --- /dev/null +++ b/Source/Sync/NSArray+Sync.swift @@ -0,0 +1,37 @@ +import Foundation + + +extension NSArray { + /** + Filters the items using the provided predicate, useful to exclude JSON objects from a JSON array by using a predicate. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some items, you just need to provide this predicate. + - parameter parent: The parent of the entity, optional since many entities are orphans. + - parameter dataStack: The DataStack instance. + */ + /* + func preprocessForEntityNamed(_ entityName: String, predicate: NSPredicate, parent: NSManagedObject?, dataStack: DataStack, operations: Sync.OperationOptions) -> [[String : Any]] { + var filteredChanges = [[String : Any]]() + let validClasses = [NSDate.classForCoder(), NSNumber.classForCoder(), NSString.classForCoder()] + if let predicate = predicate as? NSComparisonPredicate, let selfArray = self as? [[String : Any]] , validClasses.contains(where: { $0 == predicate.rightExpression.classForCoder }) { + var objectChanges = [NSManagedObject]() + let context = dataStack.newDisposableMainContext() + if let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) { + for objectDictionary in selfArray { + let object = NSManagedObject(entity: entity, insertInto: context) + object.sync_fillWithDictionary(objectDictionary, parent: parent, parentRelationship: nil, dataStack: dataStack, operations: operations) + objectChanges.append(object) + } + + guard let filteredArray = (objectChanges as NSArray).filtered(using: predicate) as? [NSManagedObject] else { fatalError("Couldn't cast filteredArray as [NSManagedObject]: \(objectChanges), predicate: \(predicate)") } + for filteredObject in filteredArray { + let change = filteredObject.hyp_dictionary(using: .array) + filteredChanges.append(change) + } + } + } + + return filteredChanges + } + */ +} diff --git a/Source/Sync/NSEntityDescription+Sync.swift b/Source/Sync/NSEntityDescription+Sync.swift new file mode 100644 index 00000000..9c2da47f --- /dev/null +++ b/Source/Sync/NSEntityDescription+Sync.swift @@ -0,0 +1,26 @@ +import CoreData + +extension NSEntityDescription { + /** + Finds the relationships for the current entity. + - returns The list of relationships for the current entity. + */ + func sync_relationships() -> [NSRelationshipDescription] { + var relationships = [NSRelationshipDescription]() + for propertyDescription in properties { + if let relationshipDescription = propertyDescription as? NSRelationshipDescription { + relationships.append(relationshipDescription) + } + } + + return relationships + } + + /** + Finds the parent for the current entity, if there are many parents nil will be returned. + - returns The parent relationship for the current entity + */ + func sync_parentEntity() -> NSRelationshipDescription? { + return sync_relationships().filter { $0.destinationEntity?.name == name && !$0.isToMany }.first + } +} diff --git a/Source/Sync/NSManagedObject+Sync.swift b/Source/Sync/NSManagedObject+Sync.swift new file mode 100644 index 00000000..a7273254 --- /dev/null +++ b/Source/Sync/NSManagedObject+Sync.swift @@ -0,0 +1,375 @@ +import CoreData + + +extension NSManagedObject { + /** + Using objectID to fetch an NSManagedObject from a NSManagedContext is quite unsafe, + and has unexpected behaviour most of the time, although it has gotten better throught + the years, it's a simple method with not many moving parts. + + Copy in context gives you a similar behaviour, just a bit safer. + - parameter context: The context where the NSManagedObject will be taken + - returns: A NSManagedObject copied in the provided context. + */ + func sync_copyInContext(_ context: NSManagedObjectContext) -> NSManagedObject { + guard let entityName = self.entity.name else { fatalError("Couldn't find entity name") } + let localPrimaryKey = value(forKey: self.entity.sync_localPrimaryKey()) + guard let copiedObject = context.safeObject(entityName, localPrimaryKey: localPrimaryKey, parent: nil, parentRelationshipName: nil) else { fatalError("Couldn't fetch a safe object from entityName: \(entityName) localPrimaryKey: \(localPrimaryKey)") } + + return copiedObject + } + + /** + Syncs the entity using the received dictionary, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter dictionary: The JSON with the changes to be applied to the entity. + - parameter parent: The parent of the entity, optional since many entities are orphans. + - parameter dataStack: The DataStack instance. + */ + func sync_fill(with dictionary: [String: Any], parent: NSManagedObject?, parentRelationship: NSRelationshipDescription?, context: NSManagedObjectContext, operations: Sync.OperationOptions, shouldContinueBlock: (() -> Bool)?, objectJSONBlock: ((_ objectJSON: [String: Any]) -> [String: Any])?) { + hyp_fill(with: dictionary) + + for relationship in entity.sync_relationships() { + let suffix = relationship.isToMany ? "_ids" : "_id" + let constructedKeyName = relationship.name.hyp_snakeCase() + suffix + let keyName = relationship.userInfo?[SyncCustomRemoteKey] as? String ?? constructedKeyName + + if relationship.isToMany { + if let localPrimaryKey = dictionary[keyName], localPrimaryKey is Array < String> || localPrimaryKey is Array < Int> || localPrimaryKey is NSNull { + sync_toManyRelationshipUsingIDsInsteadOfDictionary(relationship, localPrimaryKey: localPrimaryKey) + } else { + try! sync_toManyRelationship(relationship, dictionary: dictionary, parent: parent, parentRelationship: parentRelationship, context: context, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + } + } else { + var destinationIsParentSuperEntity = false + if let parent = parent, let destinationEntityName = relationship.destinationEntity?.name { + if let parentSuperEntityName = parent.entity.superentity?.name { + destinationIsParentSuperEntity = destinationEntityName == parentSuperEntityName + } + } + + var parentRelationshipIsTheSameAsCurrentRelationship = false + if let parentRelationship = parentRelationship { + parentRelationshipIsTheSameAsCurrentRelationship = parentRelationship.inverseRelationship == relationship + } + + if let parent = parent, parentRelationshipIsTheSameAsCurrentRelationship || destinationIsParentSuperEntity { + let currentValueForRelationship = self.value(forKey: relationship.name) + let newParentIsDifferentThanCurrentValue = parent.isEqual(currentValueForRelationship) == false + if newParentIsDifferentThanCurrentValue { + self.setValue(parent, forKey: relationship.name) + } + } else if let localPrimaryKey = dictionary[keyName], localPrimaryKey is NSString || localPrimaryKey is NSNumber || localPrimaryKey is NSNull { + sync_toOneRelationshipUsingIDInsteadOfDictionary(relationship, localPrimaryKey: localPrimaryKey) + } else { + sync_toOneRelationship(relationship, dictionary: dictionary, context: context, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + } + } + } + } + + /** + Syncs relationships where only the ids are present, for example if your model is: User <<->> Tags (a user has many tags and a tag belongs to many users), + and your tag has a users_ids, it will try to sync using those ID instead of requiring you to provide the entire users list inside each tag. + - parameter relationship: The relationship to be synced. + - parameter localPrimaryKey: The localPrimaryKey of the relationship to be synced, usually an array of strings or numbers. + */ + func sync_toManyRelationshipUsingIDsInsteadOfDictionary(_ relationship: NSRelationshipDescription, localPrimaryKey: Any) { + guard let managedObjectContext = managedObjectContext else { fatalError("managedObjectContext not found") } + guard let destinationEntity = relationship.destinationEntity else { fatalError("destinationEntity not found in relationship: \(relationship)") } + guard let destinationEntityName = destinationEntity.name else { fatalError("entityName not found in entity: \(destinationEntity)") } + if localPrimaryKey is NSNull { + if value(forKey: relationship.name) != nil { + setValue(nil, forKey: relationship.name) + } + } else { + guard let remoteItems = localPrimaryKey as? NSArray else { return } + let localRelationship: NSSet + if relationship.isOrdered { + let value = self.value(forKey: relationship.name) as? NSOrderedSet ?? NSOrderedSet() + localRelationship = value.set as NSSet + } else { + localRelationship = self.value(forKey: relationship.name) as? NSSet ?? NSSet() + } + let localItems = localRelationship.value(forKey: destinationEntity.sync_localPrimaryKey()) as? NSSet ?? NSSet() + + let deletedItems = NSMutableArray(array: localItems.allObjects) + let removedRemoteItems = remoteItems as? [Any] ?? [Any]() + deletedItems.removeObjects(in: removedRemoteItems) + + let insertedItems = remoteItems.mutableCopy() as? NSMutableArray ?? NSMutableArray() + insertedItems.removeObjects(in: localItems.allObjects) + + guard insertedItems.count > 0 || deletedItems.count > 0 || (insertedItems.count == 0 && deletedItems.count == 0 && relationship.isOrdered) else { return } + let request = NSFetchRequest(entityName: destinationEntityName) + let fetchedObjects = try? managedObjectContext.fetch(request) as? [NSManagedObject] ?? [NSManagedObject]() + guard let objects = fetchedObjects else { return } + for safeObject in objects { + let currentID = safeObject.value(forKey: safeObject.entity.sync_localPrimaryKey())! + for inserted in insertedItems { + if (currentID as AnyObject).isEqual(inserted) { + if relationship.isOrdered { + let relatedObjects = mutableOrderedSetValue(forKey: relationship.name) + if !relatedObjects.contains(safeObject) { + relatedObjects.add(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } else { + let relatedObjects = mutableSetValue(forKey: relationship.name) + if !relatedObjects.contains(safeObject) { + relatedObjects.add(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } + } + } + + for deleted in deletedItems { + if (currentID as AnyObject).isEqual(deleted) { + if relationship.isOrdered { + let relatedObjects = mutableOrderedSetValue(forKey: relationship.name) + if relatedObjects.contains(safeObject) { + relatedObjects.remove(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } else { + let relatedObjects = mutableSetValue(forKey: relationship.name) + if relatedObjects.contains(safeObject) { + relatedObjects.remove(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } + } + } + } + + if relationship.isOrdered { + for safeObject in objects { + let currentID = safeObject.value(forKey: safeObject.entity.sync_localPrimaryKey())! + let remoteIndex = remoteItems.index(of: currentID) + let relatedObjects = self.mutableOrderedSetValue(forKey: relationship.name) + + let currentIndex = relatedObjects.index(of: safeObject) + if currentIndex != remoteIndex { + relatedObjects.moveObjects(at: IndexSet(integer: currentIndex), to: remoteIndex) + } + } + } + } + } + + /** + Syncs the entity's to-many relationship, it will also sync the childs of this relationship. + - parameter relationship: The relationship to be synced. + - parameter dictionary: The JSON with the changes to be applied to the entity. + - parameter parent: The parent of the entity, optional since many entities are orphans. + - parameter dataStack: The DataStack instance. + */ + func sync_toManyRelationship(_ relationship: NSRelationshipDescription, dictionary: [String: Any], parent: NSManagedObject?, parentRelationship: NSRelationshipDescription?, context: NSManagedObjectContext, operations: Sync.OperationOptions, shouldContinueBlock: (() -> Bool)?, objectJSONBlock: ((_ objectJSON: [String: Any]) -> [String: Any])?) throws { + var children: [[String: Any]]? + let childrenIsNull = relationship.userInfo?[SyncCustomRemoteKey] is NSNull || dictionary[relationship.name.hyp_snakeCase()] is NSNull || dictionary[relationship.name] is NSNull + if childrenIsNull { + children = [[String: Any]]() + + if value(forKey: relationship.name) != nil { + setValue(nil, forKey: relationship.name) + } + } else { + if let customRelationshipName = relationship.userInfo?[SyncCustomRemoteKey] as? String { + children = dictionary[customRelationshipName] as? [[String: Any]] + } else if let result = dictionary[relationship.name.hyp_snakeCase()] as? [[String: Any]] { + children = result + } else if let result = dictionary[relationship.name] as? [[String: Any]] { + children = result + } + } + + let inverseIsToMany = relationship.inverseRelationship?.isToMany ?? false + guard let managedObjectContext = managedObjectContext else { abort() } + guard let destinationEntity = relationship.destinationEntity else { abort() } + guard let childEntityName = destinationEntity.name else { abort() } + + if let children = children { + let childIDs = (children as NSArray).value(forKey: destinationEntity.sync_remotePrimaryKey()) + + if childIDs is NSNull { + if value(forKey: relationship.name) != nil { + setValue(nil, forKey: relationship.name) + } + } else { + guard let destinationEntityName = destinationEntity.name else { fatalError("entityName not found in entity: \(destinationEntity)") } + if let remoteItems = childIDs as? NSArray { + let localRelationship: NSSet + if relationship.isOrdered { + let value = self.value(forKey: relationship.name) as? NSOrderedSet ?? NSOrderedSet() + localRelationship = value.set as NSSet + } else { + localRelationship = self.value(forKey: relationship.name) as? NSSet ?? NSSet() + } + let localItems = localRelationship.value(forKey: destinationEntity.sync_localPrimaryKey()) as? NSSet ?? NSSet() + + let deletedItems = NSMutableArray(array: localItems.allObjects) + let removedRemoteItems = remoteItems as? [Any] ?? [Any]() + deletedItems.removeObjects(in: removedRemoteItems) + + let request = NSFetchRequest(entityName: destinationEntityName) + var safeLocalObjects: [NSManagedObject]? + + if deletedItems.count > 0 { + safeLocalObjects = try managedObjectContext.fetch(request) as? [NSManagedObject] ?? [NSManagedObject]() + for safeObject in safeLocalObjects! { + let currentID = safeObject.value(forKey: safeObject.entity.sync_localPrimaryKey())! + for deleted in deletedItems { + if (currentID as AnyObject).isEqual(deleted) { + if relationship.isOrdered { + let relatedObjects = mutableOrderedSetValue(forKey: relationship.name) + if relatedObjects.contains(safeObject) { + relatedObjects.remove(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } else { + let relatedObjects = mutableSetValue(forKey: relationship.name) + if relatedObjects.contains(safeObject) { + relatedObjects.remove(safeObject) + setValue(relatedObjects, forKey: relationship.name) + } + } + } + } + } + } + + if relationship.isOrdered { + let objects: [NSManagedObject] + if let safeLocalObjects = safeLocalObjects { + objects = safeLocalObjects + } else { + objects = try managedObjectContext.fetch(request) as? [NSManagedObject] ?? [NSManagedObject]() + } + for safeObject in objects { + let currentID = safeObject.value(forKey: safeObject.entity.sync_localPrimaryKey())! + let remoteIndex = remoteItems.index(of: currentID) + let relatedObjects = self.mutableOrderedSetValue(forKey: relationship.name) + + let currentIndex = relatedObjects.index(of: safeObject) + if currentIndex != remoteIndex && currentIndex != NSNotFound { + relatedObjects.moveObjects(at: IndexSet(integer: currentIndex), to: remoteIndex) + } + } + } + } + } + + var childPredicate: NSPredicate? + let manyToMany = inverseIsToMany && relationship.isToMany + if manyToMany { + if ((childIDs as Any) as AnyObject).count > 0 { + guard let entity = NSEntityDescription.entity(forEntityName: childEntityName, in: managedObjectContext) else { fatalError() } + guard let childIDsObject = childIDs as? NSObject else { fatalError() } + childPredicate = NSPredicate(format: "ANY %K IN %@", entity.sync_localPrimaryKey(), childIDsObject) + } + } else { + guard let inverseEntityName = relationship.inverseRelationship?.name else { fatalError() } + childPredicate = NSPredicate(format: "%K = %@", inverseEntityName, self) + } + + try Sync.changes(children, inEntityNamed: childEntityName, predicate: childPredicate, parent: self, parentRelationship: relationship, inContext: managedObjectContext, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + } else { + var destinationIsParentSuperEntity = false + if let parent = parent, let destinationEntityName = relationship.destinationEntity?.name { + if let parentSuperEntityName = parent.entity.superentity?.name { + destinationIsParentSuperEntity = destinationEntityName == parentSuperEntityName + } + } + + var parentRelationshipIsTheSameAsCurrentRelationship = false + if let parentRelationship = parentRelationship { + parentRelationshipIsTheSameAsCurrentRelationship = parentRelationship.inverseRelationship == relationship + } + + if let parent = parent, parentRelationshipIsTheSameAsCurrentRelationship || destinationIsParentSuperEntity { + if relationship.isOrdered { + let relatedObjects = mutableOrderedSetValue(forKey: relationship.name) + if !relatedObjects.contains(parent) { + relatedObjects.add(parent) + setValue(relatedObjects, forKey: relationship.name) + } + } else { + let relatedObjects = mutableSetValue(forKey: relationship.name) + if !relatedObjects.contains(parent) { + relatedObjects.add(parent) + setValue(relatedObjects, forKey: relationship.name) + } + } + } + } + } + + /** + Syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter relationship: The relationship to be synced. + - parameter localPrimaryKey: The localPrimaryKey of the relationship to be synced, usually a number or an integer. + - parameter dataStack: The DataStack instance. + */ + func sync_toOneRelationshipUsingIDInsteadOfDictionary(_ relationship: NSRelationshipDescription, localPrimaryKey: Any) { + guard let managedObjectContext = self.managedObjectContext else { fatalError("managedObjectContext not found") } + guard let destinationEntity = relationship.destinationEntity else { fatalError("destinationEntity not found in relationship: \(relationship)") } + guard let destinationEntityName = destinationEntity.name else { fatalError("entityName not found in entity: \(destinationEntity)") } + if localPrimaryKey is NSNull { + if value(forKey: relationship.name) != nil { + setValue(nil, forKey: relationship.name) + } + } else if let safeObject = managedObjectContext.safeObject(destinationEntityName, localPrimaryKey: localPrimaryKey, parent: self, parentRelationshipName: relationship.name) { + let currentRelationship = value(forKey: relationship.name) + if currentRelationship == nil || !(currentRelationship! as AnyObject).isEqual(safeObject) { + setValue(safeObject, forKey: relationship.name) + } + } else { + print("Trying to sync a \(self.entity.name!) \(self) with a \(destinationEntityName) with ID \(localPrimaryKey), didn't work because \(destinationEntityName) doesn't exist. Make sure the \(destinationEntityName) exists before proceeding.") + } + } + + /** + Syncs the entity's to-one relationship, it will also sync the child of this entity. + - parameter relationship: The relationship to be synced. + - parameter dictionary: The JSON with the changes to be applied to the entity. + - parameter dataStack: The DataStack instance. + */ + func sync_toOneRelationship(_ relationship: NSRelationshipDescription, dictionary: [String: Any], context: NSManagedObjectContext, operations: Sync.OperationOptions, shouldContinueBlock: (() -> Bool)?, objectJSONBlock: ((_ objectJSON: [String: Any]) -> [String: Any])?) { + var filteredObjectDictionary: [String: Any]? + + if let customRelationshipName = relationship.userInfo?[SyncCustomRemoteKey] as? String { + filteredObjectDictionary = dictionary[customRelationshipName] as? [String: Any] + } else if let result = dictionary[relationship.name.hyp_snakeCase()] as? [String: Any] { + filteredObjectDictionary = result + } else if let result = dictionary[relationship.name] as? [String: Any] { + filteredObjectDictionary = result + } + + if let toOneObjectDictionary = filteredObjectDictionary { + guard let managedObjectContext = self.managedObjectContext else { return } + guard let destinationEntity = relationship.destinationEntity else { return } + guard let entityName = destinationEntity.name else { return } + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: managedObjectContext) else { return } + + let localPrimaryKey = toOneObjectDictionary[entity.sync_remotePrimaryKey()] + let object = managedObjectContext.safeObject(entityName, localPrimaryKey: localPrimaryKey, parent: self, parentRelationshipName: relationship.name) ?? NSEntityDescription.insertNewObject(forEntityName: entityName, into: managedObjectContext) + + object.sync_fill(with: toOneObjectDictionary, parent: self, parentRelationship: relationship, context: context, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + + let currentRelationship = self.value(forKey: relationship.name) + if currentRelationship == nil || !(currentRelationship! as AnyObject).isEqual(object) { + setValue(object, forKey: relationship.name) + } + } else { + let currentRelationship = self.value(forKey: relationship.name) + if currentRelationship != nil { + setValue(nil, forKey: relationship.name) + } + } + } +} diff --git a/Source/Sync/NSManagedObjectContext+Sync.swift b/Source/Sync/NSManagedObjectContext+Sync.swift new file mode 100644 index 00000000..edfc5180 --- /dev/null +++ b/Source/Sync/NSManagedObjectContext+Sync.swift @@ -0,0 +1,66 @@ +import CoreData +import Sync.NSEntityDescription_SyncPrimaryKey + +public extension NSManagedObjectContext { + /** + Safely fetches a NSManagedObject in the current context. If no localPrimaryKey is provided then it will check for the parent entity and use that. Otherwise it will return nil. + - parameter entityName: The name of the Core Data entity. + - parameter localPrimaryKey: The primary key. + - parameter parent: The parent of the object. + - parameter parentRelationshipName: The name of the relationship with the parent. + - returns: A NSManagedObject contained in the provided context. + */ + public func safeObject(_ entityName: String, localPrimaryKey: Any?, parent: NSManagedObject?, parentRelationshipName: String?) -> NSManagedObject? { + var result: NSManagedObject? + + if let localPrimaryKey = localPrimaryKey as? NSObject, let entity = NSEntityDescription.entity(forEntityName: entityName, in: self) { + let request = NSFetchRequest(entityName: entityName) + request.predicate = NSPredicate(format: "%K = %@", entity.sync_localPrimaryKey(), localPrimaryKey) + do { + let objects = try fetch(request) + result = objects.first as? NSManagedObject + } catch { + fatalError("Failed to fetch request for entityName: \(entityName), predicate: \(request.predicate)") + } + } else if let parentRelationshipName = parentRelationshipName { + // More info: https://github.com/SyncDB/Sync/pull/72 + result = parent?.value(forKey: parentRelationshipName) as? NSManagedObject + } + + return result + } + + public func managedObjectIDs(in entityName: String, usingAsKey attributeName: String, predicate: NSPredicate?) -> [AnyHashable: NSManagedObjectID] { + var result = [AnyHashable: NSManagedObjectID]() + + self.performAndWait { + let expression = NSExpressionDescription() + expression.name = "objectID" + expression.expression = NSExpression.expressionForEvaluatedObject() + expression.expressionResultType = .objectIDAttributeType + + let request = NSFetchRequest(entityName: entityName) + request.predicate = predicate + request.resultType = .dictionaryResultType + request.propertiesToFetch = [expression, attributeName] + + do { + let objects = try self.fetch(request) + for object in objects { + let fetchedID = object[attributeName] as! NSObject + let objectID = object["objectID"] as! NSManagedObjectID + + if let _ = result[fetchedID] { + self.delete(self.object(with: objectID)) + } else { + result[fetchedID] = objectID + } + } + } catch let error as NSError { + print("error: \(error)") + } + } + + return result + } +} diff --git a/Source/Sync/Result.swift b/Source/Sync/Result.swift new file mode 100644 index 00000000..22933089 --- /dev/null +++ b/Source/Sync/Result.swift @@ -0,0 +1,102 @@ +// +// Result.swift +// +// Copyright (c) 2014-2016 Alamofire Software Foundation (http://alamofire.org/) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +/// Used to represent whether a request was successful or encountered an error. +/// +/// - success: The request and all post processing operations were successful resulting in the serialization of the +/// provided associated value. +/// +/// - failure: The request encountered an error resulting in a failure. The associated values are the original data +/// provided by the server as well as the error that caused the failure. +public enum Result { + case success(Value) + case failure(Error) + + /// Returns `true` if the result is a success, `false` otherwise. + public var isSuccess: Bool { + switch self { + case .success: + return true + case .failure: + return false + } + } + + /// Returns `true` if the result is a failure, `false` otherwise. + public var isFailure: Bool { + return !isSuccess + } + + /// Returns the associated value if the result is a success, `nil` otherwise. + public var value: Value? { + switch self { + case .success(let value): + return value + case .failure: + return nil + } + } + + /// Returns the associated error value if the result is a failure, `nil` otherwise. + public var error: Error? { + switch self { + case .success: + return nil + case .failure(let error): + return error + } + } +} + +// MARK: - CustomStringConvertible + +extension Result: CustomStringConvertible { + /// The textual representation used when written to an output stream, which includes whether the result was a + /// success or failure. + public var description: String { + switch self { + case .success: + return "SUCCESS" + case .failure: + return "FAILURE" + } + } +} + +// MARK: - CustomDebugStringConvertible + +extension Result: CustomDebugStringConvertible { + /// The debug textual representation used when written to an output stream, which includes whether the result was a + /// success or failure in addition to the value or error. + public var debugDescription: String { + switch self { + case .success(let value): + return "SUCCESS: \(value)" + case .failure(let error): + return "FAILURE: \(error)" + } + } +} diff --git a/Source/Sync/Sync+DataStack.swift b/Source/Sync/Sync+DataStack.swift new file mode 100644 index 00000000..28126938 --- /dev/null +++ b/Source/Sync/Sync+DataStack.swift @@ -0,0 +1,115 @@ + + +public extension Sync { + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter dataStack: The DataStack instance. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, dataStack: DataStack, completion: ((_ error: NSError?) -> Void)?) { + self.changes(changes, inEntityNamed: entityName, predicate: nil, dataStack: dataStack, operations: .all, completion: completion) + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter dataStack: The DataStack instance. + - parameter operations: The type of operations to be applied to the data, Insert, Update, Delete or any possible combination. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, dataStack: DataStack, operations: Sync.OperationOptions, completion: ((_ error: NSError?) -> Void)?) { + self.changes(changes, inEntityNamed: entityName, predicate: nil, dataStack: dataStack, operations: operations, completion: completion) + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter dataStack: The DataStack instance. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, dataStack: DataStack, completion: ((_ error: NSError?) -> Void)?) { + dataStack.performInNewBackgroundContext { backgroundContext in + self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: nil, parentRelationship: nil, inContext: backgroundContext, operations: .all, completion: completion) + } + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter dataStack: The DataStack instance. + - parameter operations: The type of operations to be applied to the data, Insert, Update, Delete or any possible combination. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, dataStack: DataStack, operations: Sync.OperationOptions, completion: ((_ error: NSError?) -> Void)?) { + dataStack.performInNewBackgroundContext { backgroundContext in + self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: nil, parentRelationship: nil, inContext: backgroundContext, operations: operations, completion: completion) + } + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter parent: The parent of the synced items, useful if you are syncing the childs of an object, for example + an Album has many photos, if this photos don't incldue the album's JSON object, syncing the photos JSON requires + you to send the parent album to do the proper mapping. + - parameter dataStack: The DataStack instance. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, parent: NSManagedObject, dataStack: DataStack, completion: ((_ error: NSError?) -> Void)?) { + dataStack.performInNewBackgroundContext { backgroundContext in + let safeParent = parent.sync_copyInContext(backgroundContext) + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: backgroundContext) else { fatalError("Couldn't find entity named: \(entityName)") } + let relationships = entity.relationships(forDestination: parent.entity) + var predicate: NSPredicate? + let firstRelationship = relationships.first + + if let firstRelationship = firstRelationship { + predicate = NSPredicate(format: "%K = %@", firstRelationship.name, safeParent) + } + self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: safeParent, parentRelationship: firstRelationship?.inverseRelationship, inContext: backgroundContext, operations: .all, completion: completion) + } + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter parent: The parent of the synced items, useful if you are syncing the childs of an object, for example + an Album has many photos, if this photos don't incldue the album's JSON object, syncing the photos JSON requires + - parameter context: The context where the items will be created, in general this should be a background context. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, parent: NSManagedObject?, inContext context: NSManagedObjectContext, completion: ((_ error: NSError?) -> Void)?) { + self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: parent, parentRelationship: nil, inContext: context, operations: .all, completion: completion) + } +} diff --git a/Source/Sync/Sync+NSPersistentContainer.swift b/Source/Sync/Sync+NSPersistentContainer.swift new file mode 100644 index 00000000..bd6ef28f --- /dev/null +++ b/Source/Sync/Sync+NSPersistentContainer.swift @@ -0,0 +1,151 @@ +import CoreData + +@available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) +public extension NSPersistentContainer { + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public func sync(_ changes: [[String: Any]], inEntityNamed entityName: String, completion: ((_ error: NSError?) -> Void)?) { + self.sync(changes, inEntityNamed: entityName, predicate: nil, parent: nil, parentRelationship: nil, operations: .all, completion: completion) + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter persistentContainer: The NSPersistentContainer instance. + - parameter operations: The type of operations to be applied to the data, Insert, Update, Delete or any possible combination. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public func sync(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, parent: NSManagedObject?, parentRelationship: NSRelationshipDescription?, operations: Sync.OperationOptions, completion: ((_ error: NSError?) -> Void)?) { + self.performBackgroundTask { backgroundContext in + Sync.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: parent, parentRelationship: parentRelationship, inContext: backgroundContext, operations: operations, completion: completion) + } + } + + /// Inserts or updates an object using the given changes dictionary in an specific entity. + /// + /// - Parameters: + /// - changes: The dictionary to be used to update or create the object. + /// - entityName: The name of the entity. + /// - id: The primary key. + /// - completion: The completion block. + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public func insertOrUpdate(_ changes: [String: Any], inEntityNamed entityName: String, completion: @escaping (_ result: Result) -> Void) { + self.performBackgroundTask { backgroundContext in + do { + let result = try Sync.insertOrUpdate(changes, inEntityNamed: entityName, using: backgroundContext) + let localPrimaryKey = result.entity.sync_localPrimaryKey() + let id = result.value(forKey: localPrimaryKey) + DispatchQueue.main.async { + completion(Result.success(id!)) + } + } catch let error as NSError { + DispatchQueue.main.async { + completion(Result.failure(error)) + } + } + } + } + + /// Updates an object using the given changes dictionary for the provided primary key in an specific entity. + /// + /// - Parameters: + /// - id: The primary key. + /// - changes: The dictionary to be used to update the object. + /// - entityName: The name of the entity. + /// - completion: The completion block. + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public func update(_ id: Any, with changes: [String: Any], inEntityNamed entityName: String, completion: @escaping (_ result: Result) -> Void) { + self.performBackgroundTask { backgroundContext in + do { + var updatedID: Any? + if let result = try Sync.update(id, with: changes, inEntityNamed: entityName, using: backgroundContext) { + let localPrimaryKey = result.entity.sync_localPrimaryKey() + updatedID = result.value(forKey: localPrimaryKey) + } + DispatchQueue.main.async { + completion(Result.success(updatedID!)) + } + } catch let error as NSError { + DispatchQueue.main.async { + completion(Result.failure(error)) + } + } + } + } + + /// Deletes a managed object for the provided primary key in an specific entity. + /// + /// - Parameters: + /// - id: The primary key. + /// - entityName: The name of the entity. + /// - completion: The completion block. + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public func delete(_ id: Any, inEntityNamed entityName: String, completion: @escaping (_ error: NSError?) -> Void) { + self.performBackgroundTask { backgroundContext in + do { + try Sync.delete(id, inEntityNamed: entityName, using: backgroundContext) + DispatchQueue.main.async { + completion(nil) + } + } catch let error as NSError { + DispatchQueue.main.async { + completion(error) + } + } + } + } +} + +public extension Sync { + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter persistentContainer: The NSPersistentContainer instance. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, persistentContainer: NSPersistentContainer, completion: ((_ error: NSError?) -> Void)?) { + self.changes(changes, inEntityNamed: entityName, predicate: predicate, persistentContainer: persistentContainer, operations: .all, completion: completion) + } + + /** + Syncs the entity using the received array of dictionaries, maps one-to-many, many-to-many and one-to-one relationships. + It also syncs relationships where only the id is present, for example if your model is: Company -> Employee, + and your employee has a company_id, it will try to sync using that ID instead of requiring you to provide the + entire company object inside the employees dictionary. + - parameter changes: The array of dictionaries used in the sync process. + - parameter entityName: The name of the entity to be synced. + - parameter predicate: The predicate used to filter out changes, if you want to exclude some local items to be taken in + account in the Sync process, you just need to provide this predicate. + - parameter persistentContainer: The NSPersistentContainer instance. + - parameter operations: The type of operations to be applied to the data, Insert, Update, Delete or any possible combination. + - parameter completion: The completion block, it returns an error if something in the Sync process goes wrong. + */ + @available(iOS 10, watchOS 3, tvOS 10, OSX 10.12, *) + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, persistentContainer: NSPersistentContainer, operations: Sync.OperationOptions, completion: ((_ error: NSError?) -> Void)?) { + persistentContainer.performBackgroundTask { backgroundContext in + self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: nil, parentRelationship: nil, inContext: backgroundContext, operations: operations, completion: completion) + } + } +} diff --git a/Source/Sync/Sync.swift b/Source/Sync/Sync.swift new file mode 100644 index 00000000..fe54453a --- /dev/null +++ b/Source/Sync/Sync.swift @@ -0,0 +1,317 @@ +import CoreData + + +public protocol SyncDelegate: class { + /// Called before the JSON is used to create a new NSManagedObject. + /// + /// - parameter sync: The Sync operation. + /// - parameter json: The JSON used for filling the contents of the NSManagedObject. + /// - parameter entityNamed: The name of the entity to be created. + /// - parameter parent: The new item's parent. Do not mutate the contents of this element. + /// + /// - returns: The JSON used to create the new NSManagedObject. + func sync(_ sync: Sync, willInsert json: [String: Any], in entityNamed: String, parent: NSManagedObject?) -> [String: Any] +} + +@objc public class Sync: Operation { + public weak var delegate: SyncDelegate? + + public struct OperationOptions: OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let insert = OperationOptions(rawValue: 1 << 0) + public static let update = OperationOptions(rawValue: 1 << 1) + public static let delete = OperationOptions(rawValue: 1 << 2) + public static let all: OperationOptions = [.insert, .update, .delete] + } + + var downloadFinished = false + var downloadExecuting = false + var downloadCancelled = false + + public override var isFinished: Bool { + return self.downloadFinished + } + + public override var isExecuting: Bool { + return self.downloadExecuting + } + + public override var isCancelled: Bool { + return self.downloadCancelled + } + + public override var isAsynchronous: Bool { + return !TestCheck.isTesting + } + + var changes: [[String: Any]] + var entityName: String + var predicate: NSPredicate? + var filterOperations = Sync.OperationOptions.all + var parent: NSManagedObject? + var parentRelationship: NSRelationshipDescription? + var context: NSManagedObjectContext? + unowned var dataStack: DataStack + + public init(changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate? = nil, parent: NSManagedObject? = nil, parentRelationship: NSRelationshipDescription? = nil, context: NSManagedObjectContext? = nil, dataStack: DataStack, operations: Sync.OperationOptions = .all) { + self.changes = changes + self.entityName = entityName + self.predicate = predicate + self.parent = parent + self.parentRelationship = parentRelationship + self.context = context + self.dataStack = dataStack + self.filterOperations = operations + } + + func updateExecuting(_ isExecuting: Bool) { + self.willChangeValue(forKey: "isExecuting") + self.downloadExecuting = isExecuting + self.didChangeValue(forKey: "isExecuting") + } + + func updateFinished(_ isFinished: Bool) { + self.willChangeValue(forKey: "isFinished") + self.downloadFinished = isFinished + self.didChangeValue(forKey: "isFinished") + } + + public override func start() { + if self.isCancelled { + self.updateExecuting(false) + self.updateFinished(true) + } else { + self.updateExecuting(true) + if let context = self.context { + context.perform { + self.perform(using: context) + } + } else { + self.dataStack.performInNewBackgroundContext { backgroundContext in + self.perform(using: backgroundContext) + } + } + } + } + + func perform(using context: NSManagedObjectContext) { + do { + try Sync.changes(self.changes, inEntityNamed: self.entityName, predicate: self.predicate, parent: self.parent, parentRelationship: self.parentRelationship, inContext: context, operations: self.filterOperations, shouldContinueBlock: { () -> Bool in + return !self.isCancelled + }, objectJSONBlock: { objectJSON -> [String: Any] in + return self.delegate?.sync(self, willInsert: objectJSON, in: self.entityName, parent: self.parent) ?? objectJSON + }) + } catch let error as NSError { + print("Failed syncing changes \(error)") + + self.updateExecuting(false) + self.updateFinished(true) + } + } + + public override func cancel() { + func updateCancelled(_ isCancelled: Bool) { + self.willChangeValue(forKey: "isCancelled") + self.downloadCancelled = isCancelled + self.didChangeValue(forKey: "isCancelled") + } + + updateCancelled(true) + } + + public class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, parent: NSManagedObject?, parentRelationship: NSRelationshipDescription?, inContext context: NSManagedObjectContext, operations: Sync.OperationOptions, completion: ((_ error: NSError?) -> Void)?) { + + var error: NSError? + do { + try self.changes(changes, inEntityNamed: entityName, predicate: predicate, parent: parent, parentRelationship: parentRelationship, inContext: context, operations: operations, shouldContinueBlock: nil, objectJSONBlock: nil) + } catch let syncError as NSError { + error = syncError + } + + if TestCheck.isTesting { + completion?(error) + } else { + DispatchQueue.main.async { + completion?(error) + } + } + } + + class func changes(_ changes: [[String: Any]], inEntityNamed entityName: String, predicate: NSPredicate?, parent: NSManagedObject?, parentRelationship: NSRelationshipDescription?, inContext context: NSManagedObjectContext, operations: Sync.OperationOptions, shouldContinueBlock: (() -> Bool)?, objectJSONBlock: ((_ objectJSON: [String: Any]) -> [String: Any])?) throws { + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { fatalError("Entity named \(entityName) not found.") } + + let localPrimaryKey = entity.sync_localPrimaryKey() + let remotePrimaryKey = entity.sync_remotePrimaryKey() + let shouldLookForParent = parent == nil && predicate == nil + + var finalPredicate = predicate + if let parentEntity = entity.sync_parentEntity(), shouldLookForParent { + finalPredicate = NSPredicate(format: "%K = nil", parentEntity.name) + } + + if localPrimaryKey.isEmpty { + fatalError("Local primary key not found for entity: \(entityName), add a primary key named id or mark an existing attribute using hyper.isPrimaryKey") + } + + if remotePrimaryKey.isEmpty { + fatalError("Remote primary key not found for entity: \(entityName), we were looking for id, if your remote ID has a different name consider using hyper.remoteKey to map to the right value") + } + + let dataFilterOperations = DataFilter.Operation(rawValue: operations.rawValue) + DataFilter.changes(changes, inEntityNamed: entityName, predicate: finalPredicate, operations: dataFilterOperations, localPrimaryKey: localPrimaryKey, remotePrimaryKey: remotePrimaryKey, context: context, inserted: { JSON in + let shouldContinue = shouldContinueBlock?() ?? true + guard shouldContinue else { return } + + let created = NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) + let interceptedJSON = objectJSONBlock?(JSON) ?? JSON + created.sync_fill(with: interceptedJSON, parent: parent, parentRelationship: parentRelationship, context: context, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + }) { JSON, updatedObject in + let shouldContinue = shouldContinueBlock?() ?? true + guard shouldContinue else { return } + + updatedObject.sync_fill(with: JSON, parent: parent, parentRelationship: parentRelationship, context: context, operations: operations, shouldContinueBlock: shouldContinueBlock, objectJSONBlock: objectJSONBlock) + } + + if context.hasChanges { + let shouldContinue = shouldContinueBlock?() ?? true + if shouldContinue { + try context.save() + } else { + context.reset() + } + } + } + + /// Fetches a managed object for the provided primary key in an specific entity. + /// + /// - Parameters: + /// - id: The primary key. + /// - entityName: The name of the entity. + /// - context: The context to be used, make sure that this method gets called in the same thread as the context using `perform` or `performAndWait`. + /// - Returns: A managed object for a provided primary key in an specific entity. + /// - Throws: Core Data related issues. + @discardableResult + public class func fetch(_ id: Any, inEntityNamed entityName: String, using context: NSManagedObjectContext) throws -> ResultType? { + Sync.verifyContextSafety(context: context) + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { abort() } + let localPrimaryKey = entity.sync_localPrimaryKey() + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: "%K = %@", localPrimaryKey, id as! NSObject) + + let objects = try context.fetch(fetchRequest) + + return objects.first + } + + /// Inserts or updates an object using the given changes dictionary in an specific entity. + /// + /// - Parameters: + /// - changes: The dictionary to be used to update or create the object. + /// - entityName: The name of the entity. + /// - context: The context to be used, make sure that this method gets called in the same thread as the context using `perform` or `performAndWait`. + /// - Returns: The inserted or updated object. If you call this method from a background context, make sure to not use this on the main thread. + /// - Throws: Core Data related issues. + @discardableResult + public class func insertOrUpdate(_ changes: [String: Any], inEntityNamed entityName: String, using context: NSManagedObjectContext) throws -> ResultType { + Sync.verifyContextSafety(context: context) + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { abort() } + let localPrimaryKey = entity.sync_localPrimaryKey() + let remotePrimaryKey = entity.sync_remotePrimaryKey() + guard let id = changes[remotePrimaryKey] as? NSObject else { fatalError("Couldn't find primary key \(remotePrimaryKey) in JSON for object in entity \(entityName)") } + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: "%K = %@", localPrimaryKey, id) + + let fetchedObjects = try context.fetch(fetchRequest) + let insertedOrUpdatedObjects: [ResultType] + if fetchedObjects.count > 0 { + insertedOrUpdatedObjects = fetchedObjects + } else { + let inserted = NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) as! ResultType + insertedOrUpdatedObjects = [inserted] + } + + for object in insertedOrUpdatedObjects { + object.sync_fill(with: changes, parent: nil, parentRelationship: nil, context: context, operations: [.all], shouldContinueBlock: nil, objectJSONBlock: nil) + } + + if context.hasChanges { + try context.save() + } + + return insertedOrUpdatedObjects.first! + } + + /// Updates an object using the given changes dictionary for the provided primary key in an specific entity. + /// + /// - Parameters: + /// - id: The primary key. + /// - changes: The dictionary to be used to update the object. + /// - entityName: The name of the entity. + /// - context: The context to be used, make sure that this method gets called in the same thread as the context using `perform` or `performAndWait`. + /// - Returns: The updated object, if not found it returns nil. If you call this method from a background context, make sure to not use this on the main thread. + /// - Throws: Core Data related issues. + @discardableResult + public class func update(_ id: Any, with changes: [String: Any], inEntityNamed entityName: String, using context: NSManagedObjectContext) throws -> ResultType? { + Sync.verifyContextSafety(context: context) + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { fatalError("Couldn't find an entity named \(entityName)") } + let localPrimaryKey = entity.sync_localPrimaryKey() + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: "%K = %@", localPrimaryKey, id as! NSObject) + + let objects = try context.fetch(fetchRequest) + for updated in objects { + updated.sync_fill(with: changes, parent: nil, parentRelationship: nil, context: context, operations: [.all], shouldContinueBlock: nil, objectJSONBlock: nil) + } + + if context.hasChanges { + try context.save() + } + + return objects.first + } + + /// Deletes a managed object for the provided primary key in an specific entity. + /// + /// - Parameters: + /// - id: The primary key. + /// - entityName: The name of the entity. + /// - context: The context to be used, make sure that this method gets called in the same thread as the context using `perform` or `performAndWait`. + /// - Throws: Core Data related issues. + public class func delete(_ id: Any, inEntityNamed entityName: String, using context: NSManagedObjectContext) throws { + Sync.verifyContextSafety(context: context) + + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { abort() } + let localPrimaryKey = entity.sync_localPrimaryKey() + let fetchRequest = NSFetchRequest(entityName: entityName) + fetchRequest.predicate = NSPredicate(format: "%K = %@", localPrimaryKey, id as! NSObject) + + let objects = try context.fetch(fetchRequest) + guard objects.count > 0 else { return } + + for deletedObject in objects { + context.delete(deletedObject) + } + + if context.hasChanges { + try context.save() + } + } + + fileprivate class func verifyContextSafety(context: NSManagedObjectContext) { + if Thread.isMainThread && context.concurrencyType == .privateQueueConcurrencyType { + fatalError("Background context used in the main thread. Use context's `perform` method") + } + + if !Thread.isMainThread && context.concurrencyType == .mainQueueConcurrencyType { + fatalError("Main context used in a background thread. Use context's `perform` method.") + } + } +} diff --git a/Source/TestCheck/TestCheck.swift b/Source/TestCheck/TestCheck.swift new file mode 100755 index 00000000..d699a2f3 --- /dev/null +++ b/Source/TestCheck/TestCheck.swift @@ -0,0 +1,56 @@ +/* + https://github.com/SyncDB/TestCheck + + Licensed under the **MIT** license + + > Copyright (c) 2015 Elvis Nuñez + > Copyright (c) 2016 SyncDB + > + > Permission is hereby granted, free of charge, to any person obtaining + > a copy of this software and associated documentation files (the + > "Software"), to deal in the Software without restriction, including + > without limitation the rights to use, copy, modify, merge, publish, + > distribute, sublicense, and/or sell copies of the Software, and to + > permit persons to whom the Software is furnished to do so, subject to + > the following conditions: + > + > The above copyright notice and this permission notice shall be + > included in all copies or substantial portions of the Software. + > + > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + > IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + > CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + > TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + > SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import Foundation + +@objc public class TestCheck: NSObject { + /** + Method to check wheter your on testing mode or not. + - returns: A Bool, `true` if you're on testing mode, `false` if you're not. + */ + static public let isTesting: Bool = { + let enviroment = ProcessInfo().environment + let serviceName = enviroment["XPC_SERVICE_NAME"] + let injectBundle = enviroment["XCInjectBundle"] + var isRunning = (enviroment["TRAVIS"] != nil || enviroment["XCTestConfigurationFilePath"] != nil) + + if !isRunning { + if let serviceName = serviceName { + isRunning = (serviceName as NSString).pathExtension == "xctest" + } + } + + if !isRunning { + if let injectBundle = injectBundle { + isRunning = (injectBundle as NSString).pathExtension == "xctest" + } + } + + return isRunning + }() +}