From c0a49b2a8528b752a6b2bb977c2f8e1cedc4541c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Apr 2023 23:12:11 +0000 Subject: [PATCH] [Feature] Persistence (#5) This PR contains the work done to implement some useful protocols, classes and extensions to use to setup a persistence layer in an application. To provide further details about the work done: - [x] declared the `Persistence` target in the Package file; - [x] forgot to declare the `Communications` and `Persistence` target to the `SwiftLibs` library in the `Package` file; - [x] defined the `Service` public protocol; - [x] implemented the `Fetcher` generic class; - [x] implemented the `bitBucket` static property in the `URL+Devices` public extension; - [x] updated the `README` file. Co-authored-by: Javier Cicchelli Reviewed-on: https://repo.rock-n-code.com/rock-n-code/swift-libs/pulls/5 --- Package.swift | 15 +- README.md | 1 + Sources/Persistence/Classes/Fetcher.swift | 165 +++++++++ .../Persistence/Extensions/URL+Devices.swift | 18 + Sources/Persistence/Protocols/Service.swift | 36 ++ Tests/Persistence/Classes/FetcherTests.swift | 319 ++++++++++++++++++ .../Extensions/URL+DevicesTests.swift | 30 ++ .../Helpers/NSFetchRequest+TestEntity.swift | 24 ++ .../Model.xcdatamodel/contents | 4 + .../Helpers/TestPersistenceService.swift | 127 +++++++ 10 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 Sources/Persistence/Classes/Fetcher.swift create mode 100644 Sources/Persistence/Extensions/URL+Devices.swift create mode 100644 Sources/Persistence/Protocols/Service.swift create mode 100644 Tests/Persistence/Classes/FetcherTests.swift create mode 100644 Tests/Persistence/Extensions/URL+DevicesTests.swift create mode 100644 Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift create mode 100644 Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents create mode 100644 Tests/Persistence/Helpers/TestPersistenceService.swift diff --git a/Package.swift b/Package.swift index 591d13e..c27328b 100644 --- a/Package.swift +++ b/Package.swift @@ -20,9 +20,11 @@ let package = Package( .library( name: "SwiftLibs", targets: [ + "Communications", "Coordination", "Core", - "Dependencies" + "Dependencies", + "Persistence" ] ), ], @@ -46,6 +48,10 @@ let package = Package( name: "Dependencies", dependencies: [] ), + .target( + name: "Persistence", + dependencies: [] + ), // MARK: Test targets .testTarget( name: "CommunicationsTests", @@ -76,6 +82,13 @@ let package = Package( ], path: "Tests/Dependencies" ), + .testTarget( + name: "PersistenceTests", + dependencies: [ + "Persistence" + ], + path: "Tests/Persistence" + ), ] ) diff --git a/README.md b/README.md index c45716b..2982e8b 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,4 @@ Currently, this package contains the following libraries: * `Coordination`: protocols to implement the [Coordinator pattern](https://khanlou.com/2015/01/the-coordinator/) and some ready-to-use platform-specific concrete routers; * `Core`: extensions we usually add to the base layer functionality and primitive types provided by the [Swift standard library](https://https://www.swift.org/documentation/#standard-library); * `Dependencies`: a ready-to-use, simple [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) mechanism that levers heavily on the [dynamic property wrappers](https://www.hackingwithswift.com/plus/intermediate-swiftui/creating-a-custom-property-wrapper-using-dynamicproperty) provided by the [Swift programming language](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/#Projecting-a-Value-From-a-Property-Wrapper); +* `Persistence`: protocols, extensions and a ready-to-use fetcher class to simplify the building of the [CoreData](https://developer.apple.com/documentation/coredata) persistence layer; diff --git a/Sources/Persistence/Classes/Fetcher.swift b/Sources/Persistence/Classes/Fetcher.swift new file mode 100644 index 0000000..60bda74 --- /dev/null +++ b/Sources/Persistence/Classes/Fetcher.swift @@ -0,0 +1,165 @@ +// +// Fetcher.swift +// Persistence +// +// Created by Javier Cicchelli on 16/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Combine +import CoreData + +/// This class fetches objects from a given managed object context and it notifies of changes in the object fetched if any. +public class Fetcher: NSObject, NSFetchedResultsControllerDelegate { + + // MARK: Properties + + /// The publisher that emits the changes detected to the Location entities in a given object context. + public let didChangePublisher = PassthroughSubject<[Change], Never>() + + private let fetchedResultsController: NSFetchedResultsController + + /// The number of sections in the data. + public var numberOfSections: Int { + fetchedResultsController.sections?.count ?? 0 + } + + private var changesToNotify: [Change] = [] + + // MARK: Initialisers + + /// Initialises the fetcher give the given parameters. + /// - Parameters: + /// - fetchRequest: The fetch request to use to get the objects. + /// - managedObjectContext: The managed object context against the fetch request is executed. + /// - sectionNameKeyPath: A key path on result objects that returns the section name. + /// - cacheName: The name of the cache file the receiver should use. + public init( + fetchRequest: NSFetchRequest, + managedObjectContext: NSManagedObjectContext, + sectionNameKeyPath: String? = nil, + cacheName: String? = nil + ) { + self.fetchedResultsController = .init( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: sectionNameKeyPath, + cacheName: cacheName + ) + + super.init() + + self.fetchedResultsController.delegate = self + } + + // MARK: Functions + + /// Perform the fetching. + public func fetch() throws { + try fetchedResultsController.performFetch() + } + + /// Retrieve the number of objects in a given section number. + /// - Parameter section: The section number to inquiry about. + /// - Returns: A number of objects in the given section number. + public func numberOfObjects(in section: Int) throws -> Int { + guard let sections = fetchedResultsController.sections else { + throw FetcherError.fetchNotExecuted + } + guard sections.endIndex > section else { + throw FetcherError.sectionNotFound + } + + return sections[section].numberOfObjects + } + + /// Retrieve an object out of a given index path. + /// - Parameter indexPath: The index path to use to retrieve an object. + /// - Returns: A `NSManagedObject` entity positioned in the given index path. + public func object(at indexPath: IndexPath) throws -> Model { + guard fetchedResultsController.sections != nil else { + throw FetcherError.fetchNotExecuted + } + + return fetchedResultsController.object(at: indexPath) + } + + // MARK: NSFetchedResultsControllerDelegate + + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + changesToNotify.removeAll() + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + didChangePublisher.send(changesToNotify) + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange sectionInfo: NSFetchedResultsSectionInfo, + atSectionIndex sectionIndex: Int, + for type: NSFetchedResultsChangeType + ) { + if type == .insert { + changesToNotify.append(.section(.inserted(sectionIndex))) + } else if type == .delete { + changesToNotify.append(.section(.deleted(sectionIndex))) + } + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + switch type { + case .insert: + guard let newIndexPath else { return } + + changesToNotify.append(.object(.inserted(at: newIndexPath))) + case .delete: + guard let indexPath else { return } + + changesToNotify.append(.object(.deleted(from: indexPath))) + case .move: + guard let indexPath, let newIndexPath else { return } + + changesToNotify.append(.object(.moved(from: indexPath, to: newIndexPath))) + case .update: + guard let indexPath else { return } + + changesToNotify.append(.object(.updated(at: indexPath))) + default: + break + } + } + +} + +// MARK: - Errors + +public enum FetcherError: Error { + case fetchNotExecuted + case sectionNotFound +} + +// MARK: - Enumerations + +public enum Change: Hashable { + public enum SectionUpdate: Hashable { + case inserted(Int) + case deleted(Int) + } + + public enum ObjectUpdate: Hashable { + case inserted(at: IndexPath) + case deleted(from: IndexPath) + case updated(at: IndexPath) + case moved(from: IndexPath, to: IndexPath) + } + + case section(SectionUpdate) + case object(ObjectUpdate) +} diff --git a/Sources/Persistence/Extensions/URL+Devices.swift b/Sources/Persistence/Extensions/URL+Devices.swift new file mode 100644 index 0000000..9297c23 --- /dev/null +++ b/Sources/Persistence/Extensions/URL+Devices.swift @@ -0,0 +1,18 @@ +// +// URL+Devices.swift +// Persistence +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Foundation + +public extension URL { + + // MARK: Properties + + /// URL to a virtual device in which any data written vanishes or disappear. + static let bitBucket = URL(fileURLWithPath: "/dev/null") + +} diff --git a/Sources/Persistence/Protocols/Service.swift b/Sources/Persistence/Protocols/Service.swift new file mode 100644 index 0000000..e628743 --- /dev/null +++ b/Sources/Persistence/Protocols/Service.swift @@ -0,0 +1,36 @@ +// +// Service.swift +// Persistence +// +// Created by Javier Cicchelli on 13/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +public protocol Service { + + // MARK: Properties + + /// The main managed object context. + var viewContext: NSManagedObjectContext { get } + + // MARK: Functions + + /// Create a private queue context. + /// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use. + func makeTaskContext() -> NSManagedObjectContext + + /// Create a child context of the view context. + /// - Returns: A generated child `NSManagedObjectContext` context instance ready to use. + func makeChildContext() -> NSManagedObjectContext + + /// Save a given context. + /// - Parameter context: A `NSManagedObjectContext` context instance to save. + func save(context: NSManagedObjectContext) throws + + /// Save a given child context as well as its respective parent context. + /// - Parameter context: A child `NSManagedObjectContext` context instance to save. + func save(childContext context: NSManagedObjectContext) throws + +} diff --git a/Tests/Persistence/Classes/FetcherTests.swift b/Tests/Persistence/Classes/FetcherTests.swift new file mode 100644 index 0000000..facead0 --- /dev/null +++ b/Tests/Persistence/Classes/FetcherTests.swift @@ -0,0 +1,319 @@ +// +// FetcherTests.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Combine +import Persistence +import XCTest + +final class FetcherTests: XCTestCase { + + // MARK: Properties + + private lazy var persistence: TestPersistenceService = .shared + private lazy var fetcher: Fetcher = .init( + fetchRequest: .allTestEntities(), + managedObjectContext: persistence.viewContext + ) + + // MARK: Setup + + override func tearDownWithError() throws { + try persistence.clean() + } + + // MARK: Number of sections tests + + func test_numberOfSections_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 1) + } + + func test_numberOfSections_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let _ = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 1) + } + + func test_numberOfSections_whenNoFetch() async throws { + // GIVEN + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 0) + } + + // MARK: Number of objects tests + + func test_numberOfObjects_inFirstSection_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN + let section = fetcher.numberOfSections - 1 + let numberOfObjects = try fetcher.numberOfObjects(in: section) + + // THEN + XCTAssertEqual(numberOfObjects, 0) + } + + func test_numberOfObjects_inFirstSection_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN + let section = fetcher.numberOfSections - 1 + let numberOfObjects = try fetcher.numberOfObjects(in: section) + + // THEN + XCTAssertEqual(numberOfObjects, entities.count) + } + + func test_numberOfObjects_inNonExistingSection() throws { + // GIVEN + try fetcher.fetch() + + // WHEN & THEN + let section = fetcher.numberOfSections + + XCTAssertThrowsError(try fetcher.numberOfObjects(in: section)) { error in + XCTAssertEqual(error as? FetcherError, .sectionNotFound) + } + } + + func test_numberOfObjects_whenNoFetch() throws { + // GIVEN + // WHEN & THEN + XCTAssertThrowsError(try fetcher.numberOfObjects(in: 1)) { error in + XCTAssertEqual(error as? FetcherError, .fetchNotExecuted) + } + } + + // MARK: Object at tests + + func test_objectAt_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN & THEN + let _ = IndexPath( + item: 0, + section: fetcher.numberOfSections - 1 + ) + + // TODO: Need to find out how to handle NSInvalidArgumentException in this test. + // let object = try fetcher.object(at: indexPath) + } + + func test_objectAt_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [TestEntity(context: context)] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN & THEN + let indexPath = IndexPath( + item: entities.count - 1, + section: fetcher.numberOfSections - 1 + ) + + let object = try fetcher.object(at: indexPath) + + XCTAssertNotNil(object) + } + + func test_objectAt_withOutOfBoundsIndexPath() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [TestEntity(context: context)] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN & THEN + let _ = IndexPath( + item: entities.count, + section: fetcher.numberOfSections + ) + + // TODO: Need to find out how to handle NSInvalidArgumentException in this test. + // let object = try fetcher.object(at: indexPath) + } + + func test_objectAt_whenNoFetch() throws { + // GIVEN + // WHEN & THEN + let indexPath = IndexPath( + item: 0, + section: 0 + ) + + XCTAssertThrowsError(try fetcher.object(at: indexPath)) { error in + XCTAssertEqual(error as? FetcherError, .fetchNotExecuted) + } + } + + // MARK: Did change publisher tests + + func test_didChangePublisher_whenModelIsEmpty() throws { + let expectation = self.expectation(description: "didChangePublisher when model is filled.") + + var result: [Change]? + + // GIVEN + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try fetcher.fetch() + + // THEN + let waiter = XCTWaiter.wait(for: [expectation], timeout: 1.0) + + guard waiter == .timedOut else { + XCTFail("Waiter expected to time out.") + return + } + + cancellable.cancel() + + XCTAssertNil(result) + } + + func test_didChangePublisher_whenModelIsFilled() throws { + let expectation = self.expectation(description: "didChangePublisher when model is filled.") + + var result: [Change]? + + // GIVEN + let context = persistence.makeChildContext() + let _ = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try persistence.save(childContext: context) + try fetcher.fetch() + + // THEN + let waiter = XCTWaiter.wait(for: [expectation], timeout: 1.0) + + guard waiter == .timedOut else { + XCTFail("Waiter expected to time out.") + return + } + + cancellable.cancel() + + XCTAssertNil(result) + } + + func test_didChangePublisher_whenModelIsUpdated() throws { + let expectation = self.expectation(description: "didChangePublisher when model is updated.") + + var result: [Change]? + + // GIVEN + let context = persistence.makeChildContext() + let entities = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try fetcher.fetch() + try persistence.save(childContext: context) + + // THEN + waitForExpectations(timeout: 1.0) + + cancellable.cancel() + + XCTAssertNotNil(result) + XCTAssertEqual(result?.count, entities.count) + XCTAssertEqual(result, [ + .object(.inserted(at: .init(item: 2, section: 0))), + .object(.inserted(at: .init(item: 1, section: 0))), + .object(.inserted(at: .init(item: 0, section: 0))), + ]) + } + +} + +// MARK: - TestPersistenceService+Functions + +private extension TestPersistenceService { + + // MARK: Functions + + func clean() throws { + let context = makeChildContext() + + try context.performAndWait { + try context + .fetch(.allTestEntities()) + .forEach(context.delete) + } + + try save(childContext: context) + } + +} diff --git a/Tests/Persistence/Extensions/URL+DevicesTests.swift b/Tests/Persistence/Extensions/URL+DevicesTests.swift new file mode 100644 index 0000000..a4f0ae5 --- /dev/null +++ b/Tests/Persistence/Extensions/URL+DevicesTests.swift @@ -0,0 +1,30 @@ +// +// URL+DevicesTests.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Foundation +import Persistence +import XCTest + +final class URL_DevicesTests: XCTestCase { + + // MARK: Properties + + private var url: URL! + + // MARK: - Tests + + func test_bitBucket() { + // GIVEN + // WHEN + url = .bitBucket + + // THEN + XCTAssertEqual(url.absoluteString, "file:///dev/null") + } + +} diff --git a/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift b/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift new file mode 100644 index 0000000..bfa2468 --- /dev/null +++ b/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift @@ -0,0 +1,24 @@ +// +// NSFetchRequest+TestEntity.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +extension NSFetchRequest where ResultType == TestEntity { + + // MARK: Functions + + static func allTestEntities() -> NSFetchRequest { + let request = TestEntity.fetchRequest() + + request.sortDescriptors = [] + request.resultType = .managedObjectResultType + + return request + } + +} diff --git a/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents b/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents new file mode 100644 index 0000000..4c66cba --- /dev/null +++ b/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Tests/Persistence/Helpers/TestPersistenceService.swift b/Tests/Persistence/Helpers/TestPersistenceService.swift new file mode 100644 index 0000000..ee33433 --- /dev/null +++ b/Tests/Persistence/Helpers/TestPersistenceService.swift @@ -0,0 +1,127 @@ +// +// TestPersistenceService.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData +import Persistence + +struct TestPersistenceService { + + // MARK: Properties + + static let shared = TestPersistenceService() + + private let container: NSPersistentContainer + + var viewContext: NSManagedObjectContext { + container.viewContext + } + + // MARK: Initialisers + + init() { + guard + let modelURL = Bundle.module.url(forResource: .Model.name, withExtension: .Model.extension), + let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) + else { + fatalError("Could not load the Core Data model from the module.") + } + + self.container = .init( + name: .Model.name, + managedObjectModel: managedObjectModel + ) + + setContainer() + } + +} + +// MARK: - Service + +extension TestPersistenceService: Service { + + // MARK: Functions + + func makeTaskContext() -> NSManagedObjectContext { + let taskContext = container.newBackgroundContext() + + taskContext.automaticallyMergesChangesFromParent = true + taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + return taskContext + } + + func makeChildContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + context.parent = container.viewContext + context.automaticallyMergesChangesFromParent = true + + return context + } + + func save(context: NSManagedObjectContext) throws { + guard context.hasChanges else { + return + } + + try context.save() + } + + func save(childContext context: NSManagedObjectContext) throws { + guard context.hasChanges else { + return + } + + try context.save() + + guard + let parent = context.parent, + parent == container.viewContext + else { + return + } + + try parent.performAndWait { + try parent.save() + } + } + +} + +// MARK: - Helpers + +private extension TestPersistenceService { + + // MARK: Functions + + func setContainer() { + container.persistentStoreDescriptions = [ + .init(url: .bitBucket) + ] + + container.loadPersistentStores { _, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + + container.viewContext.automaticallyMergesChangesFromParent = false + } + +} + +// MARK: - String+Constants + +private extension String { + enum Model { + static let name = "TestModel" + static let `extension` = "momd" + } +}