[Feature] Persistence #5

Merged
javier merged 8 commits from feature/persistence into main 2023-04-17 23:12:12 +00:00
10 changed files with 738 additions and 1 deletions

View File

@ -20,9 +20,11 @@ let package = Package(
.library( .library(
name: "SwiftLibs", name: "SwiftLibs",
targets: [ targets: [
"Communications",
"Coordination", "Coordination",
"Core", "Core",
"Dependencies" "Dependencies",
"Persistence"
] ]
), ),
], ],
@ -46,6 +48,10 @@ let package = Package(
name: "Dependencies", name: "Dependencies",
dependencies: [] dependencies: []
), ),
.target(
name: "Persistence",
dependencies: []
),
// MARK: Test targets // MARK: Test targets
.testTarget( .testTarget(
name: "CommunicationsTests", name: "CommunicationsTests",
@ -76,6 +82,13 @@ let package = Package(
], ],
path: "Tests/Dependencies" path: "Tests/Dependencies"
), ),
.testTarget(
name: "PersistenceTests",
dependencies: [
"Persistence"
],
path: "Tests/Persistence"
),
] ]
) )

View File

@ -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; * `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); * `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); * `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;

View File

@ -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<Model: NSManagedObject>: 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<Model>
/// 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<Model>,
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<NSFetchRequestResult>) {
changesToNotify.removeAll()
}
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
didChangePublisher.send(changesToNotify)
}
public func controller(
_ controller: NSFetchedResultsController<NSFetchRequestResult>,
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<NSFetchRequestResult>,
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)
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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<TestEntity> = .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)
}
}

View File

@ -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")
}
}

View File

@ -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<TestEntity> {
let request = TestEntity.fetchRequest()
request.sortDescriptors = []
request.resultType = .managedObjectResultType
return request
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22E261" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class"/>
</model>

View File

@ -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"
}
}