diff --git a/CoreStore.xcodeproj/project.pbxproj b/CoreStore.xcodeproj/project.pbxproj index be1d808b..8c6c9e47 100644 --- a/CoreStore.xcodeproj/project.pbxproj +++ b/CoreStore.xcodeproj/project.pbxproj @@ -723,6 +723,9 @@ B5FEC18F1C9166E600532541 /* NSPersistentStore+Setup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FEC18D1C9166E200532541 /* NSPersistentStore+Setup.swift */; }; B5FEC1901C9166E700532541 /* NSPersistentStore+Setup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FEC18D1C9166E200532541 /* NSPersistentStore+Setup.swift */; }; B5FEC1911C9166E700532541 /* NSPersistentStore+Setup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FEC18D1C9166E200532541 /* NSPersistentStore+Setup.swift */; }; + E6B8DCE522229E340007BBA9 /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8DCE422229E330007BBA9 /* MigrationTests.swift */; }; + E6B8DCE622229E340007BBA9 /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8DCE422229E330007BBA9 /* MigrationTests.swift */; }; + E6B8DCE722229E340007BBA9 /* MigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8DCE422229E330007BBA9 /* MigrationTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -968,6 +971,7 @@ B5FE4DA61C84FB4400FA6A91 /* InMemoryStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryStore.swift; sourceTree = ""; }; B5FE4DAB1C85D44E00FA6A91 /* SQLiteStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteStore.swift; sourceTree = ""; }; B5FEC18D1C9166E200532541 /* NSPersistentStore+Setup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSPersistentStore+Setup.swift"; sourceTree = ""; }; + E6B8DCE422229E330007BBA9 /* MigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1101,6 +1105,7 @@ B5220E0B1D0D0D19009BC71E /* ImportTests.swift */, B525576B1CFAF18F00E51965 /* IntoTests.swift */, B5220E0F1D0DA6AB009BC71E /* ListObserverTests.swift */, + E6B8DCE422229E330007BBA9 /* MigrationTests.swift */, B5DC47C51C93D22900FA3BF3 /* MigrationChainTests.swift */, B5220E071D0C5F8D009BC71E /* ObjectObserverTests.swift */, B52557771D02826E00E51965 /* OrderByTests.swift */, @@ -2039,6 +2044,7 @@ B5220E0C1D0D0D19009BC71E /* ImportTests.swift in Sources */, B5D339B41E925C2B00C880DE /* DynamicModelTests.swift in Sources */, B5D372841A39CD6900F583D9 /* Model.xcdatamodeld in Sources */, + E6B8DCE522229E340007BBA9 /* MigrationTests.swift in Sources */, B52557881D02DE8100E51965 /* FetchTests.swift in Sources */, B5489F501CF603D5008B4978 /* FromTests.swift in Sources */, B52557781D02826E00E51965 /* OrderByTests.swift in Sources */, @@ -2234,6 +2240,7 @@ B5220E0D1D0D0D19009BC71E /* ImportTests.swift in Sources */, B5D339B51E925C2B00C880DE /* DynamicModelTests.swift in Sources */, B525576D1CFAF18F00E51965 /* IntoTests.swift in Sources */, + E6B8DCE622229E340007BBA9 /* MigrationTests.swift in Sources */, B580857B1CDF808D004C2EEB /* SetupTests.swift in Sources */, B52557891D02DE8100E51965 /* FetchTests.swift in Sources */, B5489F511CF603D5008B4978 /* FromTests.swift in Sources */, @@ -2429,6 +2436,7 @@ B525576E1CFAF18F00E51965 /* IntoTests.swift in Sources */, B5D339B61E925C2B00C880DE /* DynamicModelTests.swift in Sources */, B580857C1CDF808F004C2EEB /* SetupTests.swift in Sources */, + E6B8DCE722229E340007BBA9 /* MigrationTests.swift in Sources */, B525578A1D02DE8100E51965 /* FetchTests.swift in Sources */, B5220E281D1308E5009BC71E /* SectionByTests.swift in Sources */, B5489F521CF603D5008B4978 /* FromTests.swift in Sources */, diff --git a/CoreStoreTests/MigrationTests.swift b/CoreStoreTests/MigrationTests.swift new file mode 100644 index 00000000..9374c911 --- /dev/null +++ b/CoreStoreTests/MigrationTests.swift @@ -0,0 +1,235 @@ +// +// MigrationChainTests.swift +// CoreStore +// +// Copyright © 2018 John Rommel Estropia +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import XCTest + +@testable +import CoreStore + + +// MARK: - MigrationTests + +final class MigrationTests: BaseTestCase { + + func test_ThatEntityDescriptionExtension_CanMapAttributes() { + + // Should match attributes by renaming identifier. + do { + let src = NSEntityDescription([NSAttributeDescription("foo")]) + let dst = NSEntityDescription([NSAttributeDescription("bar", renamingIdentifier: "foo")]) + + var map: [NSAttributeDescription: NSAttributeDescription] = [:] + XCTAssertNoThrow(map = try dst.mapAttributes(in: src)) + XCTAssertEqual(map.count, 1) + XCTAssertEqual(map.keys.first?.renamingIdentifier, "foo") + XCTAssertEqual(map.values.first?.name, "foo") + } + + // Should match attributes by name when matching by renaming identifier fails. + do { + let src = NSEntityDescription([NSAttributeDescription("bar")]) + let dst = NSEntityDescription([NSAttributeDescription("bar", renamingIdentifier: "foo")]) + + var map: [NSAttributeDescription: NSAttributeDescription] = [:] + XCTAssertNoThrow(map = try dst.mapAttributes(in: src)) + XCTAssertEqual(map.count, 1) + XCTAssertEqual(map.keys.first?.renamingIdentifier, "foo") + XCTAssertEqual(map.keys.first?.name, "bar") + XCTAssertEqual(map.values.first?.name, "bar") + } + + // Should not throw exception when optional attributes cannot be matched. + do { + let src = NSEntityDescription([NSAttributeDescription("foo")]) + let dst = NSEntityDescription([NSAttributeDescription("bar")]) + + var map: [NSAttributeDescription: NSAttributeDescription] = [:] + XCTAssertNoThrow(map = try dst.mapAttributes(in: src)) + XCTAssertEqual(map.count, 0) + } + + // Should not throw exception when required attributes with default value cannot be matched. + do { + let src = NSEntityDescription([NSAttributeDescription("foo")]) + let dst = NSEntityDescription([NSAttributeDescription("bar", optional: false, defaultValue: "baz")]) + + var map: [NSAttributeDescription: NSAttributeDescription] = [:] + XCTAssertNoThrow(map = try dst.mapAttributes(in: src)) + XCTAssertEqual(map.count, 0) + } + + // Should throw exception when required attributes without default value cannot be matched. + do { + let src = NSEntityDescription([NSAttributeDescription("foo")]) + let dst = NSEntityDescription([NSAttributeDescription("bar", optional: false)]) + XCTAssertThrowsError(try dst.mapAttributes(in: src)) + } + } + + func test_ThatCustomSchemaMappingProvider_CanDeleteAndInsertEntitiesWithCustomEntityMapping() { + class Foo: CoreStoreObject { + var name = Value.Optional("name") + } + + class Bar: CoreStoreObject { + var nickname = Value.Optional("nickname", renamingIdentifier: "name") + } + + let src: CoreStoreSchema = CoreStoreSchema(modelVersion: "1", entities: [Entity("Foo")]) + let dst: CoreStoreSchema = CoreStoreSchema(modelVersion: "2", entities: [Entity("Bar")]) + + let migration: CustomSchemaMappingProvider = CustomSchemaMappingProvider(from: "1", to: "2", entityMappings: [ + .deleteEntity(sourceEntity: "Foo"), + .insertEntity(destinationEntity: "Bar") + ]) + + /// Create the source store and data set. + withExtendedLifetime(DataStack(src), { stack in + try! stack.addStorageAndWait(SQLiteStore()) + try! stack.perform(synchronous: { $0.create(Into()).name.value = "Willy" }) + }) + + let expectation: XCTestExpectation = self.expectation(description: "migration-did-complete") + + withExtendedLifetime(DataStack(src, dst, migrationChain: ["1", "2"]), { stack in + _ = stack.addStorage(SQLiteStore(fileURL: SQLiteStore.defaultFileURL, migrationMappingProviders: [migration]), completion: { + switch $0 { + case .success(_): + XCTAssertEqual(stack.modelSchema.rawModel().entities.count, 1) + try! stack.perform(synchronous: { $0.create(Into()).nickname.value = "Bobby" }) + case .failure(let error): + XCTFail("\(error)") + } + expectation.fulfill() + }) + }) + + self.waitAndCheckExpectations() + } + + func test_ThatCustomSchemaMappingProvider_CanCopyEntityWithCustomEntityMapping() { + class Foo: CoreStoreObject { + var name = Value.Required("name", initial: "") + } + + // Todo: The way this handles different version locks is flaky… It fails face on the ground in debug, but seems + // todo: to work fine in production, yet it's not clear if it transforms everything as expected. + + let src: CoreStoreSchema = CoreStoreSchema(modelVersion: "1", entities: [Entity("Foo")]) + let dst: CoreStoreSchema = CoreStoreSchema(modelVersion: "2", entities: [Entity("Foo")]) + + XCTAssertEqual(dst.rawModel().entities.first!.versionHash, src.rawModel().entities.first!.versionHash) + + let migration: CustomSchemaMappingProvider = CustomSchemaMappingProvider(from: "1", to: "2", entityMappings: [ + .copyEntity(sourceEntity: "Foo", destinationEntity: "Foo") + ]) + + /// Create the source store and data set. + withExtendedLifetime(DataStack(src), { stack in + try! stack.addStorageAndWait(SQLiteStore()) + try! stack.perform(synchronous: { $0.create(Into()).name.value = "Willy" }) + }) + + let expectation: XCTestExpectation = self.expectation(description: "migration-did-complete") + + withExtendedLifetime(DataStack(src, dst, migrationChain: ["1", "2"]), { stack in + _ = stack.addStorage(SQLiteStore(fileURL: SQLiteStore.defaultFileURL, migrationMappingProviders: [migration]), completion: { + switch $0 { + case .success(_): + XCTAssertEqual(stack.modelSchema.rawModel().entities.count, 1) + XCTAssertEqual(try! stack.fetchCount(From()), 1) + try! stack.perform(synchronous: { $0.create(Into()).name.value = "Bobby" }) + case .failure(let error): + XCTFail("\(error)") + } + expectation.fulfill() + }) + }) + + self.waitAndCheckExpectations() + } + + func test_ThatCustomSchemaMappingProvider_CanTransformEntityWithCustomEntityMapping() { + class Foo: CoreStoreObject { + var name = Value.Required("name", initial: "") + var futile = Value.Required("futile", initial: "") + } + + class Bar: CoreStoreObject { + var firstName = Value.Required("firstName", initial: "", renamingIdentifier: "name") + var lastName = Value.Required("lastName", initial: "", renamingIdentifier: "placeholder") + var age = Value.Required("age", initial: 18) + var gender = Value.Optional("gender") + } + + let src: CoreStoreSchema = CoreStoreSchema(modelVersion: "1", entities: [Entity("Foo")]) + let dst: CoreStoreSchema = CoreStoreSchema(modelVersion: "2", entities: [Entity("Bar")]) + + let migration: CustomSchemaMappingProvider = CustomSchemaMappingProvider(from: "1", to: "2", entityMappings: [ + .transformEntity(sourceEntity: "Foo", destinationEntity: "Bar", transformer: CustomSchemaMappingProvider.CustomMapping.inferredTransformation) + ]) + + /// Create the source store and data set. + withExtendedLifetime(DataStack(src), { stack in + try! stack.addStorageAndWait(SQLiteStore()) + try! stack.perform(synchronous: { $0.create(Into()).name.value = "Willy" }) + }) + + let expectation: XCTestExpectation = self.expectation(description: "migration-did-complete") + + withExtendedLifetime(DataStack(src, dst, migrationChain: ["1", "2"]), { stack in + _ = stack.addStorage(SQLiteStore(fileURL: SQLiteStore.defaultFileURL, migrationMappingProviders: [migration]), completion: { + switch $0 { + case .success(_): + XCTAssertEqual(stack.modelSchema.rawModel().entities.count, 1) + XCTAssertEqual(try! stack.fetchCount(From()), 1) + try! stack.perform(synchronous: { $0.create(Into()).firstName.value = "Bobby" }) + case .failure(let error): + XCTFail("\(error)") + } + expectation.fulfill() + }) + }) + + self.waitAndCheckExpectations() + } +} + +extension NSEntityDescription { + fileprivate convenience init(_ properties: [NSPropertyDescription]) { + self.init() + self.properties = properties + } +} + +extension NSAttributeDescription { + fileprivate convenience init(_ name: String, renamingIdentifier: String? = nil, optional: Bool? = nil, defaultValue: Any? = nil) { + self.init() + self.name = name + self.renamingIdentifier = renamingIdentifier + self.isOptional = optional ?? true + self.defaultValue = defaultValue + } +} diff --git a/CoreStoreTests/SetupTests.swift b/CoreStoreTests/SetupTests.swift index b053c005..69ac2414 100644 --- a/CoreStoreTests/SetupTests.swift +++ b/CoreStoreTests/SetupTests.swift @@ -231,7 +231,7 @@ class SetupTests: BaseTestDataTestCase { ) try sqliteStore.cs_eraseStorageAndWait( metadata: metadata, - soureModelHint: stack.schemaHistory.schema(for: metadata)?.rawModel() + sourceModelHint: stack.schemaHistory.schema(for: metadata)?.rawModel() ) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-wal"))) @@ -244,7 +244,7 @@ class SetupTests: BaseTestDataTestCase { do { let metadata = try createStore() - try sqliteStore.cs_eraseStorageAndWait(metadata: metadata, soureModelHint: nil) + try sqliteStore.cs_eraseStorageAndWait(metadata: metadata, sourceModelHint: nil) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-wal"))) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-shm"))) @@ -358,7 +358,7 @@ class SetupTests: BaseTestDataTestCase { ) try sqliteStore.cs_eraseStorageAndWait( metadata: metadata, - soureModelHint: stack.schemaHistory.schema(for: metadata)?.rawModel() + sourceModelHint: stack.schemaHistory.schema(for: metadata)?.rawModel() ) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-wal"))) @@ -371,7 +371,7 @@ class SetupTests: BaseTestDataTestCase { do { let metadata = try createStore() - try sqliteStore.cs_eraseStorageAndWait(metadata: metadata, soureModelHint: nil) + try sqliteStore.cs_eraseStorageAndWait(metadata: metadata, sourceModelHint: nil) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path)) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-wal"))) XCTAssertFalse(fileManager.fileExists(atPath: sqliteStore.fileURL.path.appending("-shm"))) diff --git a/README.md b/README.md index 8edd35c5..55117b80 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ public protocol LocalStorage: StorageInterface { var migrationMappingProviders: [SchemaMappingProvider] { get } var localStorageOptions: LocalStorageOptions { get } func dictionary(forOptions: LocalStorageOptions) -> [String: AnyObject]? - func cs_eraseStorageAndWait(metadata: [String: Any], soureModelHint: NSManagedObjectModel?) throws + func cs_eraseStorageAndWait(metadata: [String: Any], sourceModelHint: NSManagedObjectModel?) throws } ``` If you have custom `NSIncrementalStore` or `NSAtomicStore` subclasses, you can implement this protocol and use it similarly to `SQLiteStore`. @@ -463,7 +463,7 @@ if you do so, any model mismatch will be thrown as an error. In general though, if migrations are expected the asynchronous variant `addStorage(_:completion:)` method is recommended instead: ```swift -let migrationProgress: Progress? = try dataStack.addStorage( +let migrationProgress: Progress? = dataStack.addStorage( SQLiteStore( fileName: "MyStore.sqlite", configuration: "Config2" diff --git a/Sources/CSSQliteStore.swift b/Sources/CSSQliteStore.swift index e84e29b7..ddf6467f 100644 --- a/Sources/CSSQliteStore.swift +++ b/Sources/CSSQliteStore.swift @@ -151,11 +151,11 @@ public final class CSSQLiteStore: NSObject, CSLocalStorage, CoreStoreObjectiveCT Called by the `CSDataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. For `CSSQLiteStore`, this converts the database's WAL journaling mode to DELETE before deleting the file. */ @objc - public func cs_eraseStorageAndWait(metadata: NSDictionary, soureModelHint: NSManagedObjectModel?, error: NSErrorPointer) -> Bool { + public func cs_eraseStorageAndWait(metadata: NSDictionary, sourceModelHint: NSManagedObjectModel?, error: NSErrorPointer) -> Bool { return bridge(error) { - try self.bridgeToSwift.cs_eraseStorageAndWait(metadata: metadata as! [String: Any], soureModelHint: soureModelHint) + try self.bridgeToSwift.cs_eraseStorageAndWait(metadata: metadata as! [String: Any], sourceModelHint: sourceModelHint) } } diff --git a/Sources/CSStorageInterface.swift b/Sources/CSStorageInterface.swift index d8e09069..5c8bb2b4 100644 --- a/Sources/CSStorageInterface.swift +++ b/Sources/CSStorageInterface.swift @@ -121,5 +121,5 @@ public protocol CSLocalStorage: CSStorageInterface { Called by the `CSDataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (SQLite stores for example, can convert WAL journaling mode to DELETE before deleting) */ @objc - func cs_eraseStorageAndWait(metadata: NSDictionary, soureModelHint: NSManagedObjectModel?, error: NSErrorPointer) -> Bool + func cs_eraseStorageAndWait(metadata: NSDictionary, sourceModelHint: NSManagedObjectModel?, error: NSErrorPointer) -> Bool } diff --git a/Sources/CustomSchemaMappingProvider.swift b/Sources/CustomSchemaMappingProvider.swift index 505e530c..eb02af97 100644 --- a/Sources/CustomSchemaMappingProvider.swift +++ b/Sources/CustomSchemaMappingProvider.swift @@ -345,124 +345,116 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { // MARK: SchemaMappingProvider public func cs_createMappingModel(from sourceSchema: DynamicSchema, to destinationSchema: DynamicSchema, storage: LocalStorage) throws -> (mappingModel: NSMappingModel, migrationType: MigrationType) { - let sourceModel = sourceSchema.rawModel() let destinationModel = destinationSchema.rawModel() - let mappingModel = NSMappingModel() + let (deleteMappings, insertMappings, copyMappings, transformMappings) = self.resolveEntityMappings(sourceModel: sourceModel, destinationModel: destinationModel) - let (deleteMappings, insertMappings, copyMappings, transformMappings) = self.resolveEntityMappings( - sourceModel: sourceModel, - destinationModel: destinationModel - ) + // Todo: What is this? Why is it needed? How does it work? func expression(forSource sourceEntity: NSEntityDescription) -> NSExpression { - return NSExpression(format: "FETCH(FUNCTION($\(NSMigrationManagerKey), \"fetchRequestForSourceEntityNamed:predicateString:\" , \"\(sourceEntity.name!)\", \"\(NSPredicate(value: true))\"), FUNCTION($\(NSMigrationManagerKey), \"\(#selector(getter: NSMigrationManager.sourceContext))\"), \(false))") } let sourceEntitiesByName = sourceModel.entitiesByName let destinationEntitiesByName = destinationModel.entitiesByName - var entityMappings: [NSEntityMapping] = [] + + // Todo: Throw errors, don't do explicit unwraps!!! + for case .deleteEntity(let sourceEntityName) in deleteMappings { - let sourceEntity = sourceEntitiesByName[sourceEntityName]! - let entityMapping = NSEntityMapping() + entityMapping.sourceEntityName = sourceEntity.name entityMapping.sourceEntityVersionHash = sourceEntity.versionHash entityMapping.mappingType = .removeEntityMappingType - entityMapping.sourceExpression = expression(forSource: sourceEntity) entityMappings.append(entityMapping) } + for case .insertEntity(let destinationEntityName) in insertMappings { - let destinationEntity = destinationEntitiesByName[destinationEntityName]! + var attributeMappings: [NSPropertyMapping] = [] + var relationshipMappings: [NSPropertyMapping] = [] + + for (_, destinationAttribute) in destinationEntity.attributesByName { + let propertyMapping = NSPropertyMapping() + propertyMapping.name = destinationAttribute.name + attributeMappings.append(propertyMapping) + } + + for (_, destinationRelationship) in destinationEntity.relationshipsByName { + let propertyMapping = NSPropertyMapping() + propertyMapping.name = destinationRelationship.name + relationshipMappings.append(propertyMapping) + } let entityMapping = NSEntityMapping() + entityMapping.destinationEntityName = destinationEntity.name entityMapping.destinationEntityVersionHash = destinationEntity.versionHash entityMapping.mappingType = .addEntityMappingType - entityMapping.attributeMappings = autoreleasepool { () -> [NSPropertyMapping] in - - var attributeMappings: [NSPropertyMapping] = [] - for (_, destinationAttribute) in destinationEntity.attributesByName { - - let propertyMapping = NSPropertyMapping() - propertyMapping.name = destinationAttribute.name - attributeMappings.append(propertyMapping) - } - return attributeMappings - } - entityMapping.relationshipMappings = autoreleasepool { () -> [NSPropertyMapping] in - - var relationshipMappings: [NSPropertyMapping] = [] - for (_, destinationRelationship) in destinationEntity.relationshipsByName { - - let propertyMapping = NSPropertyMapping() - propertyMapping.name = destinationRelationship.name - relationshipMappings.append(propertyMapping) - } - return relationshipMappings - } + entityMapping.attributeMappings = attributeMappings + entityMapping.relationshipMappings = relationshipMappings + entityMappings.append(entityMapping) } + for case .copyEntity(let sourceEntityName, let destinationEntityName) in copyMappings { - let sourceEntity = sourceEntitiesByName[sourceEntityName]! let destinationEntity = destinationEntitiesByName[destinationEntityName]! + var attributeMappings: [NSPropertyMapping] = [] + var relationshipMappings: [NSPropertyMapping] = [] + + for (destinationAttribute, sourceAttribute) in try destinationEntity.mapAttributes(in: sourceEntity) { + let propertyMapping = NSPropertyMapping() + propertyMapping.name = destinationAttribute.name + propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceAttribute.name)\")") + attributeMappings.append(propertyMapping) + } + + for (destinationRelationship, sourceRelationship) in try destinationEntity.mapRelationships(in: sourceEntity) { + let propertyMapping = NSPropertyMapping() + propertyMapping.name = destinationRelationship.name + propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationManagerKey), \"destinationInstancesForSourceRelationshipNamed:sourceInstances:\", \"\(sourceRelationship.name)\", FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceRelationship.name)\"))") + relationshipMappings.append(propertyMapping) + } + let entityMapping = NSEntityMapping() + entityMapping.sourceEntityName = sourceEntity.name entityMapping.sourceEntityVersionHash = sourceEntity.versionHash entityMapping.destinationEntityName = destinationEntity.name entityMapping.destinationEntityVersionHash = destinationEntity.versionHash entityMapping.mappingType = .copyEntityMappingType entityMapping.sourceExpression = expression(forSource: sourceEntity) - entityMapping.attributeMappings = autoreleasepool { () -> [NSPropertyMapping] in - - let sourceAttributes = sourceEntity.cs_resolveAttributeNames() - let destinationAttributes = destinationEntity.cs_resolveAttributeRenamingIdentities() - - var attributeMappings: [NSPropertyMapping] = [] - for (renamingIdentifier, destination) in destinationAttributes { - - let sourceAttribute = sourceAttributes[renamingIdentifier]!.attribute - let destinationAttribute = destination.attribute - let propertyMapping = NSPropertyMapping() - propertyMapping.name = destinationAttribute.name - propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceAttribute.name)\")") - attributeMappings.append(propertyMapping) - } - return attributeMappings - } - entityMapping.relationshipMappings = autoreleasepool { () -> [NSPropertyMapping] in - - let sourceRelationships = sourceEntity.cs_resolveRelationshipNames() - let destinationRelationships = destinationEntity.cs_resolveRelationshipRenamingIdentities() - var relationshipMappings: [NSPropertyMapping] = [] - for (renamingIdentifier, destination) in destinationRelationships { - - let sourceRelationship = sourceRelationships[renamingIdentifier]!.relationship - let destinationRelationship = destination.relationship - let sourceRelationshipName = sourceRelationship.name - - let propertyMapping = NSPropertyMapping() - propertyMapping.name = destinationRelationship.name - propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationManagerKey), \"destinationInstancesForSourceRelationshipNamed:sourceInstances:\", \"\(sourceRelationshipName)\", FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceRelationshipName)\"))") - relationshipMappings.append(propertyMapping) - } - return relationshipMappings - } + entityMapping.attributeMappings = attributeMappings + entityMapping.relationshipMappings = relationshipMappings + entityMappings.append(entityMapping) } + for case .transformEntity(let sourceEntityName, let destinationEntityName, let transformEntity) in transformMappings { - let sourceEntity = sourceEntitiesByName[sourceEntityName]! let destinationEntity = destinationEntitiesByName[destinationEntityName]! + var attributeMappings: [KeyPathString: NSAttributeDescription] = [:] + var relationshipMappings: [NSPropertyMapping] = [] + + for (destinationAttribute, sourceAttribute) in try destinationEntity.mapAttributes(in: sourceEntity) { + attributeMappings[destinationAttribute.name] = sourceAttribute + } + + for (destinationRelationship, sourceRelationship) in try destinationEntity.mapRelationships(in: sourceEntity) { + let propertyMapping = NSPropertyMapping() + propertyMapping.name = destinationRelationship.name + propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationManagerKey), \"destinationInstancesForSourceRelationshipNamed:sourceInstances:\", \"\(sourceRelationship.name)\", FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceRelationship.name)\"))") + relationshipMappings.append(propertyMapping) + } + let entityMapping = NSEntityMapping() + entityMapping.sourceEntityName = sourceEntity.name entityMapping.sourceEntityVersionHash = sourceEntity.versionHash entityMapping.destinationEntityName = destinationEntity.name @@ -470,61 +462,22 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { entityMapping.mappingType = .customEntityMappingType entityMapping.sourceExpression = expression(forSource: sourceEntity) entityMapping.entityMigrationPolicyClassName = NSStringFromClass(CustomEntityMigrationPolicy.self) + entityMapping.relationshipMappings = relationshipMappings - var userInfo: [AnyHashable: Any] = [ - CustomEntityMigrationPolicy.UserInfoKey.transformer: transformEntity + entityMapping.userInfo = [ + CustomEntityMigrationPolicy.UserInfoKey.transformer: transformEntity, + CustomEntityMigrationPolicy.UserInfoKey.sourceAttributesByDestinationKey: attributeMappings ] - autoreleasepool { - - let sourceAttributes = sourceEntity.cs_resolveAttributeNames() - let destinationAttributes = destinationEntity.cs_resolveAttributeRenamingIdentities() - - let transformedRenamingIdentifiers = Set(destinationAttributes.keys) - .intersection(sourceAttributes.keys) - - var sourceAttributesByDestinationKey: [KeyPathString: NSAttributeDescription] = [:] - for renamingIdentifier in transformedRenamingIdentifiers { - - let sourceAttribute = sourceAttributes[renamingIdentifier]!.attribute - let destinationAttribute = destinationAttributes[renamingIdentifier]!.attribute - sourceAttributesByDestinationKey[destinationAttribute.name] = sourceAttribute - } - userInfo[CustomEntityMigrationPolicy.UserInfoKey.sourceAttributesByDestinationKey] = sourceAttributesByDestinationKey - } - entityMapping.relationshipMappings = autoreleasepool { () -> [NSPropertyMapping] in - - let sourceRelationships = sourceEntity.cs_resolveRelationshipNames() - let destinationRelationships = destinationEntity.cs_resolveRelationshipRenamingIdentities() - let transformedRenamingIdentifiers = Set(destinationRelationships.keys) - .intersection(sourceRelationships.keys) - - var relationshipMappings: [NSPropertyMapping] = [] - for renamingIdentifier in transformedRenamingIdentifiers { - - let sourceRelationship = sourceRelationships[renamingIdentifier]!.relationship - let destinationRelationship = destinationRelationships[renamingIdentifier]!.relationship - let sourceRelationshipName = sourceRelationship.name - let destinationRelationshipName = destinationRelationship.name - - let propertyMapping = NSPropertyMapping() - propertyMapping.name = destinationRelationshipName - propertyMapping.valueExpression = NSExpression(format: "FUNCTION($\(NSMigrationManagerKey), \"destinationInstancesForSourceRelationshipNamed:sourceInstances:\", \"\(sourceRelationshipName)\", FUNCTION($\(NSMigrationSourceObjectKey), \"\(#selector(NSManagedObject.value(forKey:)))\", \"\(sourceRelationshipName)\"))") - relationshipMappings.append(propertyMapping) - } - return relationshipMappings - } - entityMapping.userInfo = userInfo + entityMappings.append(entityMapping) } mappingModel.entityMappings = entityMappings - return ( - mappingModel, - .heavyweight( - sourceVersion: self.sourceVersion, - destinationVersion: self.destinationVersion - ) - ) + + return (mappingModel, .heavyweight( + sourceVersion: self.sourceVersion, + destinationVersion: self.destinationVersion + )) } @@ -585,7 +538,6 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { private let entityMappings: Set private func resolveEntityMappings(sourceModel: NSManagedObjectModel, destinationModel: NSManagedObjectModel) -> (delete: Set, insert: Set, copy: Set, transform: Set) { - var deleteMappings: Set = [] var insertMappings: Set = [] var copyMappings: Set = [] @@ -598,19 +550,13 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { let destinationRenamingIdentifiers = destinationModel.cs_resolveRenamingIdentities() let destinationEntityNames = destinationModel.entitiesByName - let removedRenamingIdentifiers = Set(sourceRenamingIdentifiers.keys) - .subtracting(destinationRenamingIdentifiers.keys) - let addedRenamingIdentifiers = Set(destinationRenamingIdentifiers.keys) - .subtracting(sourceRenamingIdentifiers.keys) - let transformedRenamingIdentifiers = Set(destinationRenamingIdentifiers.keys) - .subtracting(addedRenamingIdentifiers) - .subtracting(removedRenamingIdentifiers) + let removedRenamingIdentifiers = Set(sourceRenamingIdentifiers.keys).subtracting(destinationRenamingIdentifiers.keys) + let addedRenamingIdentifiers = Set(destinationRenamingIdentifiers.keys).subtracting(sourceRenamingIdentifiers.keys) + let transformedRenamingIdentifiers = Set(destinationRenamingIdentifiers.keys).subtracting(addedRenamingIdentifiers).subtracting(removedRenamingIdentifiers) // First pass: resolve source-destination entities for mapping in self.entityMappings { - switch mapping { - case .deleteEntity(let sourceEntity): CoreStore.assert( sourceEntityNames[sourceEntity] != nil, @@ -682,8 +628,8 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { allMappedDestinationKeys[destinationEntity] = sourceEntity } } + for renamingIdentifier in transformedRenamingIdentifiers { - let sourceEntity = sourceRenamingIdentifiers[renamingIdentifier]!.entity let destinationEntity = destinationRenamingIdentifiers[renamingIdentifier]!.entity let sourceEntityName = sourceEntity.name! @@ -724,8 +670,8 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { continue } } + for renamingIdentifier in removedRenamingIdentifiers { - let sourceEntity = sourceRenamingIdentifiers[renamingIdentifier]!.entity let sourceEntityName = sourceEntity.name! switch allMappedSourceKeys[sourceEntityName] { @@ -738,8 +684,8 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { continue } } + for renamingIdentifier in addedRenamingIdentifiers { - let destinationEntity = destinationRenamingIdentifiers[renamingIdentifier]!.entity let destinationEntityName = destinationEntity.name! switch allMappedDestinationKeys[destinationEntityName] { @@ -752,6 +698,7 @@ public class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider { continue } } + return (deleteMappings, insertMappings, copyMappings, transformMappings) } } diff --git a/Sources/DataStack+Migration.swift b/Sources/DataStack+Migration.swift index bda75e17..a92d2de7 100644 --- a/Sources/DataStack+Migration.swift +++ b/Sources/DataStack+Migration.swift @@ -175,7 +175,7 @@ extension DataStack { try storage.cs_eraseStorageAndWait( metadata: metadata, - soureModelHint: self.schemaHistory.schema(for: metadata)?.rawModel() + sourceModelHint: self.schemaHistory.schema(for: metadata)?.rawModel() ) _ = try self.addStorageAndWait(storage) @@ -341,7 +341,7 @@ extension DataStack { ) _ = try self.schemaHistory .schema(for: metadata) - .flatMap({ try storage.cs_eraseStorageAndWait(soureModel: $0.rawModel()) }) + .flatMap({ try storage.cs_eraseStorageAndWait(sourceModel: $0.rawModel()) }) _ = try self.createPersistentStoreFromStorage( storage, finalURL: cacheFileURL, @@ -685,7 +685,7 @@ extension DataStack { do { - try storage.cs_finalizeStorageAndWait(soureModelHint: sourceModel) + try storage.cs_finalizeStorageAndWait(sourceModelHint: sourceModel) } catch { @@ -739,7 +739,7 @@ extension DataStack { fakeProgress = 1 } - _ = try? storage.cs_finalizeStorageAndWait(soureModelHint: destinationModel) + _ = try? storage.cs_finalizeStorageAndWait(sourceModelHint: destinationModel) progress.completedUnitCount = progress.totalUnitCount return } @@ -801,7 +801,7 @@ extension DataStack { migrationMappingProviders: storage.migrationMappingProviders, localStorageOptions: storage.localStorageOptions ) - try temporaryStorage.cs_finalizeStorageAndWait(soureModelHint: destinationModel) + try temporaryStorage.cs_finalizeStorageAndWait(sourceModelHint: destinationModel) } catch { diff --git a/Sources/DataStack.swift b/Sources/DataStack.swift index 6a320c2f..3cc1db17 100644 --- a/Sources/DataStack.swift +++ b/Sources/DataStack.swift @@ -335,7 +335,7 @@ public final class DataStack: Equatable { ) try storage.cs_eraseStorageAndWait( metadata: metadata, - soureModelHint: self.schemaHistory.schema(for: metadata)?.rawModel() + sourceModelHint: self.schemaHistory.schema(for: metadata)?.rawModel() ) let finalStoreOptions = storage.dictionary(forOptions: storage.localStorageOptions) _ = try self.createPersistentStoreFromStorage( @@ -442,7 +442,7 @@ public final class DataStack: Equatable { ) _ = try self.schemaHistory .schema(for: metadata) - .flatMap({ try storage.cs_eraseStorageAndWait(soureModel: $0.rawModel()) }) + .flatMap({ try storage.cs_eraseStorageAndWait(sourceModel: $0.rawModel()) }) _ = try self.createPersistentStoreFromStorage( storage, finalURL: cacheFileURL, diff --git a/Sources/NSEntityDescription+Migration.swift b/Sources/NSEntityDescription+Migration.swift index 801ce25c..37dfe63c 100644 --- a/Sources/NSEntityDescription+Migration.swift +++ b/Sources/NSEntityDescription+Migration.swift @@ -31,31 +31,38 @@ import Foundation extension NSEntityDescription { - @nonobjc - internal func cs_resolveAttributeNames() -> [String: (attribute: NSAttributeDescription, versionHash: Data)] { - return self.attributesByName.reduce(into: [:], { (result, attribute: (name: String, description: NSAttributeDescription)) in - result[attribute.name] = (attribute.description, attribute.description.versionHash) + /// Maps attributes of the current entity with the earlier-version source entity. Todo: Log a warning if the renaming identifier is used but not found in source. + internal func mapAttributes(in sourceEntity: NSEntityDescription) throws -> [NSAttributeDescription: NSAttributeDescription] { + let sourceAttributes: [String: NSAttributeDescription] = sourceEntity.attributesByName + return try self.properties.lazy.compactMap({ $0 as? NSAttributeDescription }).reduce(into: [:], { (result, attribute: NSAttributeDescription) in + if let sourceAttribute = attribute.renamingIdentifier.flatMap({ sourceAttributes[$0] }) ?? sourceAttributes[attribute.name] { + result[attribute] = sourceAttribute + } else if !attribute.isOptional && attribute.defaultValue == nil { + throw MappingError.cannotMapAttribute(attribute: attribute) + } }) } - @nonobjc - internal func cs_resolveAttributeRenamingIdentities() -> [String: (attribute: NSAttributeDescription, versionHash: Data)] { - return self.attributesByName.reduce(into: [:], { (result, attribute: (name: String, description: NSAttributeDescription)) in - result[attribute.description.renamingIdentifier ?? attribute.name] = (attribute.description, attribute.description.versionHash) - }) - } - - @nonobjc - internal func cs_resolveRelationshipNames() -> [String: (relationship: NSRelationshipDescription, versionHash: Data)] { - return self.relationshipsByName.reduce(into: [:], { (result, relationship: (name: String, description: NSRelationshipDescription)) in - result[relationship.name] = (relationship.description, relationship.description.versionHash) - }) - } - - @nonobjc - internal func cs_resolveRelationshipRenamingIdentities() -> [String: (relationship: NSRelationshipDescription, versionHash: Data)] { - return self.relationshipsByName.reduce(into: [:], { (result, relationship: (name: String, description: NSRelationshipDescription)) in - result[relationship.description.renamingIdentifier ?? relationship.name] = (relationship.description, relationship.description.versionHash) + /// Maps relationships of the current entity with the earlier-version source entity. Todo: Log a warning if the renaming identifier is used but not found in source. + internal func mapRelationships(in sourceEntity: NSEntityDescription) throws -> [NSRelationshipDescription: NSRelationshipDescription] { + let sourceRelationships: [String: NSRelationshipDescription] = sourceEntity.relationshipsByName + return try self.properties.lazy.compactMap({ $0 as? NSRelationshipDescription }).reduce(into: [:], { (result, relationship: NSRelationshipDescription) in + if let sourceRelationship = relationship.renamingIdentifier.flatMap({ sourceRelationships[$0] }) ?? sourceRelationships[relationship.name] { + result[relationship] = sourceRelationship + } else if !relationship.isOptional { + throw MappingError.cannotMapRelationship(relationship: relationship) + } }) } } + +extension NSEntityDescription { + internal enum MappingError: Swift.Error { + + /// The required attribute without default value could not be mapped in source entity. + case cannotMapAttribute(attribute: NSAttributeDescription) + + /// The required relationship could not be mapped in source entity. + case cannotMapRelationship(relationship: NSRelationshipDescription) + } +} \ No newline at end of file diff --git a/Sources/SQLiteStore.swift b/Sources/SQLiteStore.swift index 867a7f8b..4d1098f8 100644 --- a/Sources/SQLiteStore.swift +++ b/Sources/SQLiteStore.swift @@ -216,9 +216,9 @@ public final class SQLiteStore: LocalStorage { /** Called by the `DataStack` to perform checkpoint operations on the storage. For `SQLiteStore`, this converts the database's WAL journaling mode to DELETE to force a checkpoint. */ - public func cs_finalizeStorageAndWait(soureModelHint: NSManagedObjectModel) throws { + public func cs_finalizeStorageAndWait(sourceModelHint: NSManagedObjectModel) throws { - _ = try withExtendedLifetime(NSPersistentStoreCoordinator(managedObjectModel: soureModelHint)) { (coordinator: NSPersistentStoreCoordinator) in + _ = try withExtendedLifetime(NSPersistentStoreCoordinator(managedObjectModel: sourceModelHint)) { (coordinator: NSPersistentStoreCoordinator) in var storeOptions = self.storeOptions ?? [:] storeOptions[NSSQLitePragmasOption] = ["journal_mode": "DELETE"] @@ -235,7 +235,7 @@ public final class SQLiteStore: LocalStorage { /** Called by the `DataStack` to perform actual deletion of the store file from disk. Do not call directly! The `sourceModel` argument is a hint for the existing store's model version. For `SQLiteStore`, this converts the database's WAL journaling mode to DELETE before deleting the file. */ - public func cs_eraseStorageAndWait(metadata: [String: Any], soureModelHint: NSManagedObjectModel?) throws { + public func cs_eraseStorageAndWait(metadata: [String: Any], sourceModelHint: NSManagedObjectModel?) throws { func deleteFiles(storeURL: URL, extraFiles: [String] = []) throws { @@ -283,9 +283,9 @@ public final class SQLiteStore: LocalStorage { let fileURL = self.fileURL try autoreleasepool { - if let soureModel = soureModelHint ?? NSManagedObjectModel.mergedModel(from: nil, forStoreMetadata: metadata) { + if let sourceModel = sourceModelHint ?? NSManagedObjectModel.mergedModel(from: nil, forStoreMetadata: metadata) { - let journalUpdatingCoordinator = NSPersistentStoreCoordinator(managedObjectModel: soureModel) + let journalUpdatingCoordinator = NSPersistentStoreCoordinator(managedObjectModel: sourceModel) var storeOptions = self.storeOptions ?? [:] storeOptions[NSSQLitePragmasOption] = ["journal_mode": "DELETE"] let store = try journalUpdatingCoordinator.addPersistentStore( diff --git a/Sources/StorageInterface.swift b/Sources/StorageInterface.swift index 441e47d6..8c2f0998 100644 --- a/Sources/StorageInterface.swift +++ b/Sources/StorageInterface.swift @@ -144,12 +144,12 @@ public protocol LocalStorage: StorageInterface { /** Called by the `DataStack` to perform checkpoint operations on the storage. (SQLite stores for example, can convert the database's WAL journaling mode to DELETE to force a checkpoint) */ - func cs_finalizeStorageAndWait(soureModelHint: NSManagedObjectModel) throws + func cs_finalizeStorageAndWait(sourceModelHint: NSManagedObjectModel) throws /** Called by the `DataStack` to perform actual deletion of the store file from disk. **Do not call directly!** The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (SQLite stores for example, can convert WAL journaling mode to DELETE before deleting) */ - func cs_eraseStorageAndWait(metadata: [String: Any], soureModelHint: NSManagedObjectModel?) throws + func cs_eraseStorageAndWait(metadata: [String: Any], sourceModelHint: NSManagedObjectModel?) throws } extension LocalStorage { @@ -233,7 +233,7 @@ public protocol CloudStorage: StorageInterface { /** Called by the `DataStack` to perform actual deletion of the store file from disk. **Do not call directly!** The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (Cloud stores for example, can set the NSPersistentStoreRemoveUbiquitousMetadataOption option before deleting) */ - func cs_eraseStorageAndWait(soureModel: NSManagedObjectModel) throws + func cs_eraseStorageAndWait(sourceModel: NSManagedObjectModel) throws }