diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 0d4c2c1..3ec49e4 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -16,9 +16,16 @@ DEPENDENCIES: - RxDataSources (~> 3.0) - RxSwift (~> 4.2) +SPEC REPOS: + https://github.com/cocoapods/specs.git: + - Differentiator + - RxCocoa + - RxDataSources + - RxSwift + EXTERNAL SOURCES: RxCoreData: - :path: ../ + :path: "../" SPEC CHECKSUMS: Differentiator: ffe513ce1ea4e7198b89fac94d6e281c673055a9 @@ -29,4 +36,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2301a0f899260fdadebbe33712ceb07d8d35af52 -COCOAPODS: 1.4.0 +COCOAPODS: 1.5.3 diff --git a/Example/RxCoreData.xcodeproj/project.pbxproj b/Example/RxCoreData.xcodeproj/project.pbxproj index 1cc914c..0a7d37c 100644 --- a/Example/RxCoreData.xcodeproj/project.pbxproj +++ b/Example/RxCoreData.xcodeproj/project.pbxproj @@ -7,11 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 3F313EA6210C94FB00D9D0F8 /* NSManagedObjectContext+ObserveObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9F75E4210C948B00E834AD /* NSManagedObjectContext+ObserveObjectTests.swift */; }; + 3F8A3384210C5BCD00250BCB /* Contacts.xcdatamodel in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A3383210C5BCD00250BCB /* Contacts.xcdatamodel */; }; + 3F8A3385210C5CBE00250BCB /* Bundle+Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A337C210C5AD900250BCB /* Bundle+Test.swift */; }; + 3F8A3386210C5CC100250BCB /* NSManagedObject+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A337E210C5AFB00250BCB /* NSManagedObject+Extensions.swift */; }; + 3F8A3387210C5CC500250BCB /* NSManagedObjectContext+Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A3380210C5B1000250BCB /* NSManagedObjectContext+Test.swift */; }; + 3F9F75E3210C939800E834AD /* NSManagedObjectContext+ObserveContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9F75E1210C935900E834AD /* NSManagedObjectContext+ObserveContextTests.swift */; }; 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 607FACD81AFB9204008FA782 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* ViewController.swift */; }; 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; - 607FACEC1AFB9204008FA782 /* Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACEB1AFB9204008FA782 /* Tests.swift */; }; 75696D6E47EF4301137E83D3 /* Pods_RxCoreData_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9B39454206D1A0C6433F6A /* Pods_RxCoreData_Tests.framework */; }; CF5023D00F002ADEF58941A0 /* Pods_RxCoreData_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6C2097B9C8F7CECFD35D972D /* Pods_RxCoreData_Example.framework */; }; D2AE78A51CF32FBA00D8411E /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2AE78A31CF32FBA00D8411E /* Event.swift */; }; @@ -33,6 +38,12 @@ /* Begin PBXFileReference section */ 1312AE6406B7B9929CF4EE12 /* Pods-RxCoreData_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RxCoreData_Example.release.xcconfig"; path = "Pods/Target Support Files/Pods-RxCoreData_Example/Pods-RxCoreData_Example.release.xcconfig"; sourceTree = ""; }; 3D9B39454206D1A0C6433F6A /* Pods_RxCoreData_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RxCoreData_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3F8A337C210C5AD900250BCB /* Bundle+Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Test.swift"; sourceTree = ""; }; + 3F8A337E210C5AFB00250BCB /* NSManagedObject+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject+Extensions.swift"; sourceTree = ""; }; + 3F8A3380210C5B1000250BCB /* NSManagedObjectContext+Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Test.swift"; sourceTree = ""; }; + 3F8A3383210C5BCD00250BCB /* Contacts.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Contacts.xcdatamodel; sourceTree = ""; }; + 3F9F75E1210C935900E834AD /* NSManagedObjectContext+ObserveContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+ObserveContextTests.swift"; sourceTree = ""; }; + 3F9F75E4210C948B00E834AD /* NSManagedObjectContext+ObserveObjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+ObserveObjectTests.swift"; sourceTree = ""; }; 607FACD01AFB9204008FA782 /* RxCoreData_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCoreData_Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 607FACD51AFB9204008FA782 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -41,7 +52,6 @@ 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 607FACE51AFB9204008FA782 /* RxCoreData_Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCoreData_Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 607FACEB1AFB9204008FA782 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; 609891BD0E152693BAEB65CA /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 694D2F970D4D6C2D5B237BCA /* LICENSE.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = LICENSE.md; path = ../LICENSE.md; sourceTree = ""; }; 6C2097B9C8F7CECFD35D972D /* Pods_RxCoreData_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RxCoreData_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -75,6 +85,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3F8A337B210C5AC000250BCB /* Utils */ = { + isa = PBXGroup; + children = ( + 3F8A337C210C5AD900250BCB /* Bundle+Test.swift */, + 3F8A337E210C5AFB00250BCB /* NSManagedObject+Extensions.swift */, + 3F8A3380210C5B1000250BCB /* NSManagedObjectContext+Test.swift */, + ); + name = Utils; + sourceTree = ""; + }; + 3F8A3382210C5BB100250BCB /* Resources */ = { + isa = PBXGroup; + children = ( + 3F8A3383210C5BCD00250BCB /* Contacts.xcdatamodel */, + ); + name = Resources; + sourceTree = ""; + }; 607FACC71AFB9204008FA782 = { isa = PBXGroup; children = ( @@ -124,8 +152,11 @@ 607FACE81AFB9204008FA782 /* Tests */ = { isa = PBXGroup; children = ( - 607FACEB1AFB9204008FA782 /* Tests.swift */, + 3F8A3382210C5BB100250BCB /* Resources */, + 3F8A337B210C5AC000250BCB /* Utils */, 607FACE91AFB9204008FA782 /* Supporting Files */, + 3F9F75E1210C935900E834AD /* NSManagedObjectContext+ObserveContextTests.swift */, + 3F9F75E4210C948B00E834AD /* NSManagedObjectContext+ObserveObjectTests.swift */, ); path = Tests; sourceTree = ""; @@ -182,8 +213,6 @@ 607FACCE1AFB9204008FA782 /* Resources */, 77EECB9E50343E5362FDE498 /* [CP] Embed Pods Frameworks */, 2E6B561F32E09B2E7FC48B85 /* 📦 Embed Pods Frameworks */, - 3A8ED13CDBE777B8BAAC4601 /* 📦 Copy Pods Resources */, - 81B1497C1DE64D3DEB2F0161 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -205,8 +234,6 @@ 607FACE31AFB9204008FA782 /* Resources */, BE3F737842926DCBAEF33B1C /* [CP] Embed Pods Frameworks */, 25D10640889A11CBC96C9A36 /* 📦 Embed Pods Frameworks */, - 2124F1C4EBA2827C0B8EF670 /* 📦 Copy Pods Resources */, - CD0F33E6F629F0617CA25311 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -279,21 +306,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2124F1C4EBA2827C0B8EF670 /* 📦 Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "📦 Copy Pods Resources"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Tests/Pods-RxCoreData_Tests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 25D10640889A11CBC96C9A36 /* 📦 Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -324,21 +336,6 @@ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Example/Pods-RxCoreData_Example-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 3A8ED13CDBE777B8BAAC4601 /* 📦 Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "📦 Copy Pods Resources"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Example/Pods-RxCoreData_Example-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 3F4867CBB9D45E1759703C4B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -398,21 +395,6 @@ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Example/Pods-RxCoreData_Example-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 81B1497C1DE64D3DEB2F0161 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Example/Pods-RxCoreData_Example-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 8B149104208C41903E8E6A77 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -451,21 +433,6 @@ shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Tests/Pods-RxCoreData_Tests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - CD0F33E6F629F0617CA25311 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-RxCoreData_Tests/Pods-RxCoreData_Tests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; CF5EA17E47B30114DF76F5C2 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -500,7 +467,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 607FACEC1AFB9204008FA782 /* Tests.swift in Sources */, + 3F8A3384210C5BCD00250BCB /* Contacts.xcdatamodel in Sources */, + 3F9F75E3210C939800E834AD /* NSManagedObjectContext+ObserveContextTests.swift in Sources */, + 3F8A3386210C5CC100250BCB /* NSManagedObject+Extensions.swift in Sources */, + 3F313EA6210C94FB00D9D0F8 /* NSManagedObjectContext+ObserveObjectTests.swift in Sources */, + 3F8A3385210C5CBE00250BCB /* Bundle+Test.swift in Sources */, + 3F8A3387210C5CC500250BCB /* NSManagedObjectContext+Test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Tests/Bundle+Test.swift b/Example/Tests/Bundle+Test.swift new file mode 100644 index 0000000..c25f3e3 --- /dev/null +++ b/Example/Tests/Bundle+Test.swift @@ -0,0 +1,17 @@ +// +// Bundle+Test.swift +// RxCoreData_Tests +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation + +private class Test {} + +extension Bundle { + static var test: Bundle { + return Bundle(for: Test.self) + } +} diff --git a/Example/Tests/Contacts.xcdatamodel/contents b/Example/Tests/Contacts.xcdatamodel/contents new file mode 100644 index 0000000..57cf085 --- /dev/null +++ b/Example/Tests/Contacts.xcdatamodel/contents @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Example/Tests/NSManagedObject+Extensions.swift b/Example/Tests/NSManagedObject+Extensions.swift new file mode 100644 index 0000000..b38cb7e --- /dev/null +++ b/Example/Tests/NSManagedObject+Extensions.swift @@ -0,0 +1,22 @@ +// +// NSManagedObject+Extensions.swift +// RxCoreData_Tests +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import CoreData + +extension NSManagedObject { + + class func new(in managedObjectContext: NSManagedObjectContext) -> Self { + return generateObject(type: self, in: managedObjectContext) + } + + private class func generateObject(type: T.Type, in managedObjectContext: NSManagedObjectContext) -> T { + return NSEntityDescription.insertNewObject(forEntityName: String(describing: self), into: managedObjectContext) as! T + } + +} diff --git a/Example/Tests/NSManagedObjectContext+ObserveContextTests.swift b/Example/Tests/NSManagedObjectContext+ObserveContextTests.swift new file mode 100644 index 0000000..6a7d163 --- /dev/null +++ b/Example/Tests/NSManagedObjectContext+ObserveContextTests.swift @@ -0,0 +1,129 @@ +// +// NSManagedObjectContext+ObserveContextTests.swift +// RxCoreData_Tests +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import XCTest +import RxSwift +import RxCoreData +import CoreData + +class NSManagedObjectContext_ObserveContextTests: XCTestCase { + + var testMOC: NSManagedObjectContext! + var disposeBag: DisposeBag! + + // MARK: - Setup + + override func setUp() { + super.setUp() + disposeBag = DisposeBag() + testMOC = NSManagedObjectContext.test + } + + override func tearDown() { + testMOC = nil + disposeBag = nil + super.tearDown() + } + + // MARK: - Tests + + func testObserveContext_insertion() { + let insertionExpectation = expectation(description: "Expect to get insert event") + + testMOC.rx.changes().take(1).subscribe(onNext: { changeEvent in + XCTAssertEqual(changeEvent.deleted.count, 0) + XCTAssertEqual(changeEvent.updated.count, 0) + XCTAssertEqual(changeEvent.refreshed.count, 0) + + XCTAssertEqual(changeEvent.inserted.count, 1) + let insertedGroup = changeEvent.inserted.first as? Group + XCTAssertEqual(insertedGroup?.name, "Test group") + + insertionExpectation.fulfill() + }).disposed(by: disposeBag) + + let group = Group.new(in: testMOC) + group.name = "Test group" + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObserveContext_update() { + let insertionExpectation = expectation(description: "Expect to get update event") + + let group = Group.new(in: testMOC) + group.name = "Test group" + try! testMOC.save() + + testMOC.rx.changes().take(1).subscribe(onNext: { changeEvent in + XCTAssertEqual(changeEvent.inserted.count, 0) + XCTAssertEqual(changeEvent.deleted.count, 0) + XCTAssertEqual(changeEvent.refreshed.count, 0) + + XCTAssertEqual(changeEvent.updated.count, 1) + let updatedGroup = changeEvent.updated.first as? Group + XCTAssertEqual(updatedGroup?.name, "Updated test group") + + insertionExpectation.fulfill() + }).disposed(by: disposeBag) + + group.name = "Updated test group" + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObserveContext_delete() { + let insertionExpectation = expectation(description: "Expect to get delete event") + + let group = Group.new(in: testMOC) + group.name = "Test group" + try! testMOC.save() + + testMOC.rx.changes().take(1).subscribe(onNext: { changeEvent in + XCTAssertEqual(changeEvent.inserted.count, 0) + XCTAssertEqual(changeEvent.updated.count, 0) + XCTAssertEqual(changeEvent.refreshed.count, 0) + + XCTAssertEqual(changeEvent.deleted.count, 1) + let deletedGroup = changeEvent.deleted.first as? Group + XCTAssertEqual(deletedGroup?.name, "Test group") + + insertionExpectation.fulfill() + }).disposed(by: disposeBag) + + testMOC.delete(group) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObserveContext_refresh() { + let insertionExpectation = expectation(description: "Expect to get refresh event") + + let group = Group.new(in: testMOC) + group.name = "Test group" + try! testMOC.save() + + testMOC.rx.changes().take(1).subscribe(onNext: { changeEvent in + XCTAssertEqual(changeEvent.inserted.count, 0) + XCTAssertEqual(changeEvent.updated.count, 0) + XCTAssertEqual(changeEvent.deleted.count, 0) + + XCTAssertEqual(changeEvent.refreshed.count, 1) + let refreshedGroup = changeEvent.refreshed.first as? Group + XCTAssertEqual(refreshedGroup?.name, "Test group") + + insertionExpectation.fulfill() + }).disposed(by: disposeBag) + + testMOC.refreshAllObjects() + + waitForExpectations(timeout: 1.0, handler: nil) + } + +} diff --git a/Example/Tests/NSManagedObjectContext+ObserveObjectTests.swift b/Example/Tests/NSManagedObjectContext+ObserveObjectTests.swift new file mode 100644 index 0000000..6f38700 --- /dev/null +++ b/Example/Tests/NSManagedObjectContext+ObserveObjectTests.swift @@ -0,0 +1,127 @@ +// +// NSManagedObjectContext+ObserveObjectTests.swift +// RxCoreData_Tests +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import XCTest +import RxSwift +import RxCoreData +import CoreData + +class NSManagedObjectContext_ObserveObjectTests: XCTestCase { + + var testMOC: NSManagedObjectContext! + var disposeBag: DisposeBag! + + // MARK: - Setup + + override func setUp() { + super.setUp() + disposeBag = DisposeBag() + testMOC = NSManagedObjectContext.test + } + + override func tearDown() { + testMOC = nil + disposeBag = nil + super.tearDown() + } + + // MARK: - Tests + + func testObjectFieldUpdate() { + let objectUpdateExpectation = expectation(description: "Expect to get object in stream when field is changed") + + let group = Group.new(in: testMOC) + group.name = "Test group" + try! testMOC.save() + + testMOC.rx.entity(group).take(1).subscribe(onNext: { group in + XCTAssertEqual(group.name, "Updated test group") + objectUpdateExpectation.fulfill() + }).disposed(by: disposeBag) + + group.name = "Updated test group" + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObjectOneLevelRelationshipFieldUpdate() { + let objectUpdateExpectation = expectation(description: "Expect to get object in stream when field is changed") + + let contact = Contact.new(in: testMOC) + contact.name = "John Doe" + + let group = Group.new(in: testMOC) + group.name = "Test group" + group.contacts = NSSet(objects: contact) + + try! testMOC.save() + + testMOC.rx.entity(group).take(1).subscribe(onNext: { group in + let updatedContactName = (group.contacts?.allObjects.first as? Contact)?.name + XCTAssertEqual(updatedContactName, "Alice") + + objectUpdateExpectation.fulfill() + }).disposed(by: disposeBag) + + contact.name = "Alice" + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObjectTwoLevelRelationshipFieldUpdate() { + let objectUpdateExpectation = expectation(description: "Expect to get object in stream when field is changed") + + let phoneNumber = PhoneNumber.new(in: testMOC) + phoneNumber.phoneNumber = "987654321" + phoneNumber.title = "Mobile" + + let contact = Contact.new(in: testMOC) + contact.name = "John Doe" + contact.phoneNumbers = NSSet(objects: phoneNumber) + + let group = Group.new(in: testMOC) + group.name = "Test group" + group.contacts = NSSet(objects: contact) + + try! testMOC.save() + + testMOC.rx.entity(group).take(1).subscribe(onNext: { group in + let updatedPhoneNumber = ((group.contacts?.allObjects.first as? Contact)?.phoneNumbers?.allObjects.first as? PhoneNumber)?.phoneNumber + XCTAssertEqual(updatedPhoneNumber, "987654321") + + objectUpdateExpectation.fulfill() + }).disposed(by: disposeBag) + + phoneNumber.phoneNumber = "987654321" + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testObjectDeletion() { + let objectDeleteExpectation = expectation(description: "Expect to get an error in stream when object deleted") + + let group = Group.new(in: testMOC) + group.name = "Test group" + + try! testMOC.save() + + testMOC.rx.entity(group).take(1).subscribe(onError: { error in + guard case CoreDataObserverError.objectDeleted = error else { + XCTFail("Should get CoreDataObserverError.objectDeleted error in stream") + return + } + objectDeleteExpectation.fulfill() + }).disposed(by: disposeBag) + + testMOC.delete(group) + + waitForExpectations(timeout: 1.0, handler: nil) + } + +} diff --git a/Example/Tests/NSManagedObjectContext+Test.swift b/Example/Tests/NSManagedObjectContext+Test.swift new file mode 100644 index 0000000..52358bd --- /dev/null +++ b/Example/Tests/NSManagedObjectContext+Test.swift @@ -0,0 +1,30 @@ +// +// NSManagedObjectContext+Test.swift +// RxCoreData_Tests +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + + static var test: NSManagedObjectContext { + let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.test])! + let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel) + + do { + try persistentStoreCoordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) + } catch { + fatalError("Failed to initialize in-memory store coordinator") + } + + let managedObjectContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + managedObjectContext.persistentStoreCoordinator = persistentStoreCoordinator + + return managedObjectContext + } + +} diff --git a/Example/Tests/Tests.swift b/Example/Tests/Tests.swift deleted file mode 100644 index 4027ad6..0000000 --- a/Example/Tests/Tests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import UIKit -import XCTest -import RxCoreData - -class Tests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } - - func testExample() { - // This is an example of a functional test case. - XCTAssert(true, "Pass") - } - - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure() { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Sources/Extensions/NSManagedObjectContext+Extensions.swift b/Sources/Extensions/NSManagedObjectContext+Extensions.swift new file mode 100644 index 0000000..daeb0f5 --- /dev/null +++ b/Sources/Extensions/NSManagedObjectContext+Extensions.swift @@ -0,0 +1,31 @@ +// +// NSManagedObjectContext+Extensions.swift +// RxCoreData +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import CoreData + +extension NSManagedObject { + + var relationshipIDs: Set { + var relationshipIDs = Set() + + for relationship in entity.relationshipsByName { + let relationshipObjectIds = objectIDs(forRelationshipNamed: relationship.key) + relationshipIDs.formUnion(relationshipObjectIds) + + for id in relationshipObjectIds { + if let object = managedObjectContext?.object(with: id) { + relationshipIDs.formUnion(object.relationshipIDs) + } + } + } + + return relationshipIDs + } + +} diff --git a/Sources/NSManagedObjectContext+Persistable.swift b/Sources/NSManagedObjectContext+Persistable.swift new file mode 100644 index 0000000..0de925e --- /dev/null +++ b/Sources/NSManagedObjectContext+Persistable.swift @@ -0,0 +1,75 @@ +// +// NSManagedObjectContext+Persistable.swift +// RxCoreData +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import CoreData +import RxSwift + +public extension Reactive where Base: NSManagedObjectContext { + + /** + Creates, inserts, and returns a new `NSManagedObject` instance for the given `Persistable` concrete type (defaults to `Persistable`). + */ + private func create(_ type: E.Type = E.self) -> E.T { + return NSEntityDescription.insertNewObject(forEntityName: E.entityName, into: self.base) as! E.T + } + + private func get(_ persistable: P) throws -> P.T? { + let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: P.entityName) + fetchRequest.predicate = persistable.predicate() + let result = (try self.base.execute(fetchRequest)) as! NSAsynchronousFetchResult + return result.finalResult?.first + } + + /** + Attempts to retrieve remove a `Persistable` object from a persistent store, and then attempts to commit that change or throws an error if unsuccessful. + - seealso: `Persistable` + - parameter persistable: a `Persistable` object + */ + func delete(_ persistable: P) throws { + if let entity = try get(persistable) { + self.base.delete(entity) + + do { + try entity.managedObjectContext?.save() + } catch let e { + print(e) + } + } + } + + /** + Creates and executes a fetch request and returns the fetched objects as an `Observable` array of `Persistable`. + - parameter type: the `Persistable` concrete type; defaults to `Persistable` + - parameter format: the format string for the predicate; defaults to `""` + - parameter arguments: the arguments to substitute into `format`, in the order provided; defaults to `nil` + - parameter sortDescriptors: the sort descriptors for the fetch request; defaults to `nil` + - returns: An `Observable` array of `Persistable` objects that can be bound to a table view. + */ + func entities(_ type: P.Type = P.self, + predicate: NSPredicate? = nil, + sortDescriptors: [NSSortDescriptor]? = nil) -> Observable<[P]> { + let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: P.entityName) + fetchRequest.predicate = predicate + fetchRequest.sortDescriptors = sortDescriptors ?? [NSSortDescriptor(key: P.primaryAttributeName, ascending: true)] + + return entities(fetchRequest: fetchRequest) + .map { + $0.map(P.init) + } + } + + /** + Attempts to fetch and update (or create if not found) a `Persistable` instance. Will throw error if fetch fails. + - parameter persistable: a `Persistable` instance + */ + func update(_ persistable: P) throws { + persistable.update(try get(persistable) ?? self.create(P.self)) + } + +} diff --git a/Sources/NSManagedObjectContext+Rx.swift b/Sources/NSManagedObjectContext+Rx.swift index dea978a..d2093a8 100644 --- a/Sources/NSManagedObjectContext+Rx.swift +++ b/Sources/NSManagedObjectContext+Rx.swift @@ -10,6 +10,11 @@ import Foundation import CoreData import RxSwift +public enum CoreDataObserverError: Error { + case unknown + case objectDeleted +} + public extension Reactive where Base: NSManagedObjectContext { /** @@ -20,18 +25,18 @@ public extension Reactive where Base: NSManagedObjectContext { - returns: An `Observable` array of `NSManagedObjects` objects that can be bound to a table view. */ func entities(fetchRequest: NSFetchRequest, - sectionNameKeyPath: String? = nil, - cacheName: String? = nil) -> Observable<[T]> { + sectionNameKeyPath: String? = nil, + cacheName: String? = nil) -> Observable<[T]> { return Observable.create { observer in - - let observerAdapter = FetchedResultsControllerEntityObserver(observer: observer, fetchRequest: fetchRequest, managedObjectContext: self.base, sectionNameKeyPath: sectionNameKeyPath, cacheName: cacheName) - - return Disposables.create { - observerAdapter.dispose() - } + + let observerAdapter = FetchedResultsControllerEntityObserver(observer: observer, fetchRequest: fetchRequest, managedObjectContext: self.base, sectionNameKeyPath: sectionNameKeyPath, cacheName: cacheName) + + return Disposables.create { + observerAdapter.dispose() + } } } - + /** Executes a fetch request and returns the fetched section objects as an `Observable` array of `NSFetchedResultsSectionInfo`. - parameter fetchRequest: an instance of `NSFetchRequest` to describe the search criteria used to retrieve data from a persistent store @@ -40,8 +45,8 @@ public extension Reactive where Base: NSManagedObjectContext { - returns: An `Observable` array of `NSFetchedResultsSectionInfo` objects that can be bound to a table view. */ func sections(fetchRequest: NSFetchRequest, - sectionNameKeyPath: String? = nil, - cacheName: String? = nil) -> Observable<[NSFetchedResultsSectionInfo]> { + sectionNameKeyPath: String? = nil, + cacheName: String? = nil) -> Observable<[NSFetchedResultsSectionInfo]> { return Observable.create { observer in let frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.base, @@ -54,7 +59,42 @@ public extension Reactive where Base: NSManagedObjectContext { } } } - + + /// Observes changes in current context + /// + /// - Returns: Signal that captures context change event + func changes() -> Observable { + return Observable.create { observer in + + let notificationObserver = ManagedObjectContextNotificationObserver(observer: observer, managedObjectContext: self.base) + + return Disposables.create { + notificationObserver.dispose() + } + + } + } + + /// Observe changes of provided object in current context. Reacts to all objects in relationship changes as well. + /// + /// - Parameter object: NSManagedObject to be observed + /// - Returns: Signal that return observed object every time some fields are modified + func entity(_ entity: T) -> Observable { + return changes() + .flatMap({ changeEvent -> Observable in + let deletedSet = Set(changeEvent.deleted.map({ $0.objectID })) + guard !deletedSet.contains(entity.objectID) else { + throw CoreDataObserverError.objectDeleted + } + + let interestSet = entity.relationshipIDs.union([ entity.objectID ]) + let changedSet = Set(changeEvent.updated.map({ $0.objectID })) + return Observable.just(!changedSet.intersection(interestSet).isEmpty) + }) + .filter { $0 } + .map { _ in return entity } + } + /** Performs transactional update, initiated on a separate managed object context, and propagating thrown errors. - parameter updateAction: a throwing update action @@ -72,67 +112,3 @@ public extension Reactive where Base: NSManagedObjectContext { try self.base.save() } } - -public extension Reactive where Base: NSManagedObjectContext { - - /** - Creates, inserts, and returns a new `NSManagedObject` instance for the given `Persistable` concrete type (defaults to `Persistable`). - */ - private func create(_ type: E.Type = E.self) -> E.T { - return NSEntityDescription.insertNewObject(forEntityName: E.entityName, into: self.base) as! E.T - } - - private func get(_ persistable: P) throws -> P.T? { - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: P.entityName) - fetchRequest.predicate = persistable.predicate() - let result = (try self.base.execute(fetchRequest)) as! NSAsynchronousFetchResult - return result.finalResult?.first - } - - /** - Attempts to retrieve remove a `Persistable` object from a persistent store, and then attempts to commit that change or throws an error if unsuccessful. - - seealso: `Persistable` - - parameter persistable: a `Persistable` object - */ - func delete(_ persistable: P) throws { - if let entity = try get(persistable) { - self.base.delete(entity) - - do { - try entity.managedObjectContext?.save() - } catch let e { - print(e) - } - } - } - - /** - Creates and executes a fetch request and returns the fetched objects as an `Observable` array of `Persistable`. - - parameter type: the `Persistable` concrete type; defaults to `Persistable` - - parameter format: the format string for the predicate; defaults to `""` - - parameter arguments: the arguments to substitute into `format`, in the order provided; defaults to `nil` - - parameter sortDescriptors: the sort descriptors for the fetch request; defaults to `nil` - - returns: An `Observable` array of `Persistable` objects that can be bound to a table view. - */ - func entities(_ type: P.Type = P.self, - predicate: NSPredicate? = nil, - sortDescriptors: [NSSortDescriptor]? = nil) -> Observable<[P]> { - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: P.entityName) - fetchRequest.predicate = predicate - fetchRequest.sortDescriptors = sortDescriptors ?? [NSSortDescriptor(key: P.primaryAttributeName, ascending: true)] - - return entities(fetchRequest: fetchRequest) - .map { - $0.map(P.init) - } - } - - /** - Attempts to fetch and update (or create if not found) a `Persistable` instance. Will throw error if fetch fails. - - parameter persistable: a `Persistable` instance - */ - func update(_ persistable: P) throws { - persistable.update(try get(persistable) ?? self.create(P.self)) - } - -} diff --git a/Sources/FetchedResultsControllerControllerEntityObserver.swift b/Sources/Observers/FetchedResultsControllerControllerEntityObserver.swift similarity index 99% rename from Sources/FetchedResultsControllerControllerEntityObserver.swift rename to Sources/Observers/FetchedResultsControllerControllerEntityObserver.swift index 17a5d24..54bc4cd 100644 --- a/Sources/FetchedResultsControllerControllerEntityObserver.swift +++ b/Sources/Observers/FetchedResultsControllerControllerEntityObserver.swift @@ -18,7 +18,6 @@ public final class FetchedResultsControllerEntityObserver : fileprivate let disposeBag = DisposeBag() fileprivate let frc: NSFetchedResultsController - init(observer: Observer, fetchRequest: NSFetchRequest, managedObjectContext context: NSManagedObjectContext, sectionNameKeyPath: String?, cacheName: String?) { self.observer = observer @@ -49,6 +48,7 @@ public final class FetchedResultsControllerEntityObserver : public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { sendNextElement() } + } extension FetchedResultsControllerEntityObserver : Disposable { diff --git a/Sources/FetchedResultsControllerSectionObserver.swift b/Sources/Observers/FetchedResultsControllerSectionObserver.swift similarity index 100% rename from Sources/FetchedResultsControllerSectionObserver.swift rename to Sources/Observers/FetchedResultsControllerSectionObserver.swift diff --git a/Sources/Observers/ManagedObjectContextNotificationObserver.swift b/Sources/Observers/ManagedObjectContextNotificationObserver.swift new file mode 100644 index 0000000..14c26ba --- /dev/null +++ b/Sources/Observers/ManagedObjectContextNotificationObserver.swift @@ -0,0 +1,86 @@ +// +// ManagedObjectContextNotificationObserver.swift +// RxCoreData +// +// Created by Krunoslav Zaher on 7/28/18. +// Copyright © 2018 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import CoreData +import RxSwift + +public struct CoreDataChangeEvent { + public let inserted: Set + public let updated: Set + public let deleted: Set + public let refreshed: Set +} + +class ManagedObjectContextNotificationObserver { + + typealias Observer = AnyObserver + + private let managedObjectContext: NSManagedObjectContext + private let persistentStoreCoordinator: NSPersistentStoreCoordinator? + private var notificationObserver: NSObjectProtocol? + private let observer: Observer + + init(observer: Observer, managedObjectContext: NSManagedObjectContext) { + self.managedObjectContext = managedObjectContext + self.persistentStoreCoordinator = managedObjectContext.persistentStoreCoordinator + self.observer = observer + + notificationObserver = NotificationCenter.default.addObserver(forName: Notification.Name.NSManagedObjectContextObjectsDidChange, + object: nil, + queue: nil) { [weak self] (notification) in + self?.contextObjectsDidChange(notification) + } + } + + private func contextObjectsDidChange(_ notification: Notification) { + guard let incomingContext = notification.object as? NSManagedObjectContext, + let persistentStoreCoordinator = persistentStoreCoordinator, + let incomingPersistentStoreCoordinator = incomingContext.persistentStoreCoordinator, + persistentStoreCoordinator == incomingPersistentStoreCoordinator else { + return + } + + let changeEvent = CoreDataChangeEvent(inserted: notification.coreDataInsertions, + updated: notification.coreDataUpdates, + deleted: notification.coreDataDeletions, + refreshed: notification.coreDataRefreshes) + + observer.onNext(changeEvent) + } + +} + +extension ManagedObjectContextNotificationObserver: Disposable { + + public func dispose() { + notificationObserver = nil + } + +} + + +private extension Notification { + + var coreDataInsertions: Set { + return (userInfo?[NSInsertedObjectsKey] as? Set) ?? Set() + } + + var coreDataUpdates: Set { + return (userInfo?[NSUpdatedObjectsKey] as? Set) ?? Set() + } + + var coreDataDeletions: Set { + return (userInfo?[NSDeletedObjectsKey] as? Set) ?? Set() + } + + var coreDataRefreshes: Set { + return (userInfo?[NSRefreshedObjectsKey] as? Set) ?? Set() + } + +}