From 4210df9eb600cc4874030d0c79319f9d5e280f97 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 10 Apr 2023 22:59:58 +0000 Subject: [PATCH] [Libraries] Persistence (#5) This PR contains the work that implements the Persistence service, which is used to store and serve the data of the application. To give further details on what was done: - [x] created the `Persistence`library into the **Libraries** package; - [x] defined the `Location` model into the **Model** core data model; - [x] implemented the `PersistenceService` service; - [x] removed the core data stack boilerplate code from the `AppDelegate` and `SceneDelegate` delegates in the *Locations* target. Co-authored-by: Javier Cicchelli Reviewed-on: https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/pulls/5 --- Apps/Locations/Libraries/Package.swift | 10 ++ .../Model.xcdatamodeld}/.xccurrentversion | 0 .../Locations.xcdatamodel/contents | 10 ++ .../Services/PersistenceService.swift | 133 ++++++++++++++++++ .../Services/PersistenceServiceTests.swift | 104 ++++++++++++++ .../Locations.xcdatamodel/contents | 4 - Apps/Locations/Sources/AppDelegate.swift | 45 ------ Apps/Locations/Sources/SceneDelegate.swift | 1 - DeepLinking.xcodeproj/project.pbxproj | 17 --- 9 files changed, 257 insertions(+), 67 deletions(-) rename Apps/Locations/{Resources/Locations.xcdatamodeld => Libraries/Sources/Persistence/Model.xcdatamodeld}/.xccurrentversion (100%) create mode 100644 Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents create mode 100644 Apps/Locations/Libraries/Sources/Persistence/Services/PersistenceService.swift create mode 100644 Apps/Locations/Libraries/Tests/PersistenceTests/Services/PersistenceServiceTests.swift delete mode 100644 Apps/Locations/Resources/Locations.xcdatamodeld/Locations.xcdatamodel/contents diff --git a/Apps/Locations/Libraries/Package.swift b/Apps/Locations/Libraries/Package.swift index ef3cbc4..396d480 100644 --- a/Apps/Locations/Libraries/Package.swift +++ b/Apps/Locations/Libraries/Package.swift @@ -27,6 +27,10 @@ let package = Package( "APICore" ] ), + .target( + name: "Persistence", + dependencies: [] + ), .testTarget( name: "APICoreTests", dependencies: [ @@ -40,5 +44,11 @@ let package = Package( "Locations" ] ), + .testTarget( + name: "PersistenceTests", + dependencies: [ + "Persistence" + ] + ), ] ) diff --git a/Apps/Locations/Resources/Locations.xcdatamodeld/.xccurrentversion b/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/.xccurrentversion similarity index 100% rename from Apps/Locations/Resources/Locations.xcdatamodeld/.xccurrentversion rename to Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/.xccurrentversion diff --git a/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents b/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents new file mode 100644 index 0000000..bd27564 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Model.xcdatamodeld/Locations.xcdatamodel/contents @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Apps/Locations/Libraries/Sources/Persistence/Services/PersistenceService.swift b/Apps/Locations/Libraries/Sources/Persistence/Services/PersistenceService.swift new file mode 100644 index 0000000..c81ab12 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Persistence/Services/PersistenceService.swift @@ -0,0 +1,133 @@ +// +// PersistenceService.swift +// Persistence +// +// Created by Javier Cicchelli on 10/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +public struct PersistenceService { + + // MARK: Properties + + public static let shared = PersistenceService() + public static let inMemory = PersistenceService(inMemory: true) + + public let container: NSPersistentContainer + + // MARK: Initialisers + + init(inMemory: Bool = false) { + guard + let modelURL = Bundle.module.url(forResource: .Model.name, withExtension: .Model.extension), + let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) + else { + fatalError("Could not load the model from the library.") + } + + container = NSPersistentContainer( + name: .Model.name, + managedObjectModel: managedObjectModel + ) + + setContainer(inMemory) + } + + // MARK: Functions + + /// Create a private queue context. + /// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use. + public func makeTaskContext() -> NSManagedObjectContext { + let taskContext = container.newBackgroundContext() + + taskContext.automaticallyMergesChangesFromParent = true + taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + return taskContext + } + + /// Create a child context of the view context. + /// - Returns: A generated child `NSManagedObjectContext` context instance ready to use. + public func makeChildContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + context.parent = container.viewContext + context.automaticallyMergesChangesFromParent = true + + return context + } + + /// Save a given context, + /// - Parameter context: A `NSManagedObjectContext` context instance to save. + public func save(context: NSManagedObjectContext) { + guard context.hasChanges else { + return + } + + do { + try context.save() + } catch { + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + + /// Save a given child context as well as its respective parent context. + /// - Parameter context: A child `NSManagedObjectContext` context instance to save. + public func save(childContext context: NSManagedObjectContext) { + guard context.hasChanges else { + return + } + + do { + try context.save() + + guard + let parent = context.parent, + parent == container.viewContext + else { + return + } + + try parent.performAndWait { + try parent.save() + } + } catch { + let nserror = error as NSError + fatalError("Unresolved error \(nserror), \(nserror.userInfo)") + } + } + +} + +// MARK: - Helpers + +private extension PersistenceService { + func setContainer(_ inMemory: Bool) { + container.persistentStoreDescriptions = [ + NSPersistentStoreDescription(url: + inMemory + ? URL(fileURLWithPath: "/dev/null") + : NSPersistentContainer.defaultDirectoryURL().appending(path: "\(String.Model.name).sqlite") + ) + ] + container.loadPersistentStores { _, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + container.viewContext.automaticallyMergesChangesFromParent = true + } +} + +// MARK: - String+Constants + +private extension String { + enum Model { + static let name = "Model" + static let `extension` = "momd" + } +} diff --git a/Apps/Locations/Libraries/Tests/PersistenceTests/Services/PersistenceServiceTests.swift b/Apps/Locations/Libraries/Tests/PersistenceTests/Services/PersistenceServiceTests.swift new file mode 100644 index 0000000..152beb2 --- /dev/null +++ b/Apps/Locations/Libraries/Tests/PersistenceTests/Services/PersistenceServiceTests.swift @@ -0,0 +1,104 @@ +// +// PersistenceServiceTests.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData +import XCTest + +@testable import Persistence + +final class PersistenceServiceTests: XCTestCase { + + // MARK: Properties + + private var persistence: PersistenceService! + + // MARK: Initialiser tests + + func test_initByDefault() { + // GIVEN + // WHEN + persistence = .init() + + // THEN + XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite") + XCTAssertNotNil(persistence.container.viewContext) + XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent) + } + + func test_initWithInMemory() { + // GIVEN + // WHEN + persistence = .init(inMemory: true) + + // THEN + XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null") + XCTAssertNotNil(persistence.container.viewContext) + XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent) + } + + // MARK: Static properties tests + + func test_shared() { + // GIVEN + persistence = .shared + + // WHEN + // THEN + XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.lastPathComponent, "Model.sqlite") + XCTAssertNotNil(persistence.container.viewContext) + XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent) + } + + func test_inMemory() { + // GIVEN + persistence = .inMemory + + // WHEN + // THEN + XCTAssertEqual(persistence.container.persistentStoreDescriptions.count, 1) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.type, NSSQLiteStoreType) + XCTAssertEqual(persistence.container.persistentStoreDescriptions.first?.url?.absoluteString, "file:///dev/null") + XCTAssertNotNil(persistence.container.viewContext) + XCTAssertTrue(persistence.container.viewContext.automaticallyMergesChangesFromParent) + } + + // MARK: Functions tests + + func test_makeTaskContext() { + // GIVEN + persistence = .inMemory + + // WHEN + let context = persistence.makeTaskContext() + + // THEN + XCTAssertTrue(context.automaticallyMergesChangesFromParent) + XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy) + XCTAssertNil(context.parent) + } + + func test_makeChildContext() { + // GIVEN + persistence = .inMemory + + // WHEN + let context = persistence.makeChildContext() + + // THEN + XCTAssertTrue(context.automaticallyMergesChangesFromParent) + XCTAssertTrue(context.mergePolicy as AnyObject === NSMergeByPropertyObjectTrumpMergePolicy) + XCTAssertEqual(context.parent, persistence.container.viewContext) + } + +} diff --git a/Apps/Locations/Resources/Locations.xcdatamodeld/Locations.xcdatamodel/contents b/Apps/Locations/Resources/Locations.xcdatamodeld/Locations.xcdatamodel/contents deleted file mode 100644 index 50d2514..0000000 --- a/Apps/Locations/Resources/Locations.xcdatamodeld/Locations.xcdatamodel/contents +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Apps/Locations/Sources/AppDelegate.swift b/Apps/Locations/Sources/AppDelegate.swift index d3dc549..b40c8ba 100644 --- a/Apps/Locations/Sources/AppDelegate.swift +++ b/Apps/Locations/Sources/AppDelegate.swift @@ -41,50 +41,5 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - // MARK: - Core Data stack - - lazy var persistentContainer: NSPersistentContainer = { - /* - The persistent container for the application. This implementation - creates and returns a container, having loaded the store for the - application to it. This property is optional since there are legitimate - error conditions that could cause the creation of the store to fail. - */ - let container = NSPersistentContainer(name: "Locations") - container.loadPersistentStores(completionHandler: { (storeDescription, error) in - if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ - fatalError("Unresolved error \(error), \(error.userInfo)") - } - }) - return container - }() - - // MARK: - Core Data Saving support - - func saveContext () { - let context = persistentContainer.viewContext - if context.hasChanges { - do { - try context.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nserror = error as NSError - fatalError("Unresolved error \(nserror), \(nserror.userInfo)") - } - } - } - } diff --git a/Apps/Locations/Sources/SceneDelegate.swift b/Apps/Locations/Sources/SceneDelegate.swift index b94fe63..c95cee8 100644 --- a/Apps/Locations/Sources/SceneDelegate.swift +++ b/Apps/Locations/Sources/SceneDelegate.swift @@ -62,7 +62,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // to restore the scene back to its current state. // Save changes in the application's managed object context when the application transitions to the background. - (UIApplication.shared.delegate as? AppDelegate)?.saveContext() } } diff --git a/DeepLinking.xcodeproj/project.pbxproj b/DeepLinking.xcodeproj/project.pbxproj index b324f86..6181e6f 100644 --- a/DeepLinking.xcodeproj/project.pbxproj +++ b/DeepLinking.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 46EB331B29E1CE04001D5EAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331A29E1CE04001D5EAF /* AppDelegate.swift */; }; 46EB331D29E1CE04001D5EAF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331C29E1CE04001D5EAF /* SceneDelegate.swift */; }; 46EB331F29E1CE04001D5EAF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EB331E29E1CE04001D5EAF /* ViewController.swift */; }; - 46EB332529E1CE04001D5EAF /* Locations.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */; }; 46EB332729E1CE05001D5EAF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46EB332629E1CE05001D5EAF /* Assets.xcassets */; }; 46EB332A29E1CE05001D5EAF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46EB332829E1CE05001D5EAF /* LaunchScreen.storyboard */; }; 46EB334429E1D1EC001D5EAF /* Libraries in Frameworks */ = {isa = PBXBuildFile; productRef = 46EB334329E1D1EC001D5EAF /* Libraries */; }; @@ -117,7 +116,6 @@ 46EB331A29E1CE04001D5EAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 46EB331C29E1CE04001D5EAF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 46EB331E29E1CE04001D5EAF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - 46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Locations.xcdatamodel; sourceTree = ""; }; 46EB332629E1CE05001D5EAF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 46EB332929E1CE05001D5EAF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 46EB332B29E1CE05001D5EAF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -214,7 +212,6 @@ 46EB332629E1CE05001D5EAF /* Assets.xcassets */, 46EB332B29E1CE05001D5EAF /* Info.plist */, 46EB332829E1CE05001D5EAF /* LaunchScreen.storyboard */, - 46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */, ); path = Resources; sourceTree = ""; @@ -412,7 +409,6 @@ files = ( 46EB331F29E1CE04001D5EAF /* ViewController.swift in Sources */, 46EB331B29E1CE04001D5EAF /* AppDelegate.swift in Sources */, - 46EB332529E1CE04001D5EAF /* Locations.xcdatamodeld in Sources */, 46EB331D29E1CE04001D5EAF /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -644,19 +640,6 @@ productName = Shared; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - 46EB332329E1CE04001D5EAF /* Locations.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - 46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */, - ); - currentVersion = 46EB332429E1CE04001D5EAF /* Locations.xcdatamodel */; - path = Locations.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ }; rootObject = 46EB325129E1BBD1001D5EAF /* Project object */; }