Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve custom schema mapping provider #308

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from

Conversation

iby
Copy link
Contributor

@iby iby commented Mar 16, 2019

  1. Improve property and relationship mapping.
  2. Throw errors when property or relationship cannot be mapped, don't explicitly unwrap.
  3. Add extensive schema mapping and migration tests.

iby added 6 commits April 28, 2019 08:12
Simplify the code structure and remove autorelease blocks – they introduce a certain overhead and only make sense on loops with a large number of iterations and memory-intense data that should be disposed at the end of the block.

In our case, in 99% cases there will be no more than 10 iterations (attributes and relations per entity) and most results of autorelease blocks are returned and stored into the mapping model, which defies their whole purpose in the first place.
1. This is causing a crash.
2. Based on the documentation it's not clear what it's doing here anyway.
1. Improve property and relationship mapping.
2. Throw errors when property or relationship cannot be mapped, don't explicitly unwrap.
3. Add extensive schema mapping and migration tests.
Copy link
Owner

@JohnEstropia JohnEstropia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the PR, thanks! I also apologize for not having the time to fully review your changes for quite a while.

I'm not sure we have the same presumption of how renamingIdentifiers should work. Please take a look at my comments.

Also as much as possible, please give some examples of the use cases where the previous migration logic fails and which this PR resolves.

sourceModel: sourceModel,
destinationModel: destinationModel
)
// Todo: What is this? Why is it needed? How does it work?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please try to remove ToDos and if possible keep formatting changes to a minimum.

entityMapping.sourceEntityName = sourceEntity.name
entityMapping.sourceEntityVersionHash = sourceEntity.versionHash
entityMapping.mappingType = .removeEntityMappingType
entityMapping.sourceExpression = expression(forSource: sourceEntity)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FETCH expression above is important. Is there any reason this was removed?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note here: If the migration is a lightweight migration, the database will not be recreated so old records need to be deleted explicitly.

return relationshipMappings
}
entityMapping.attributeMappings = attributeMappings
entityMapping.relationshipMappings = relationshipMappings
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it's doing the exact same thing as the previous code. Please try to avoid refactoring if there are no changes in behavior.

return relationshipMappings
}
entityMapping.attributeMappings = attributeMappings
entityMapping.relationshipMappings = relationshipMappings
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above

entityMapping.sourceEntityName = sourceEntity.name
entityMapping.sourceEntityVersionHash = sourceEntity.versionHash
entityMapping.destinationEntityName = destinationEntity.name
entityMapping.destinationEntityVersionHash = destinationEntity.versionHash
entityMapping.mappingType = .customEntityMappingType
entityMapping.sourceExpression = expression(forSource: sourceEntity)
entityMapping.entityMigrationPolicyClassName = NSStringFromClass(CustomEntityMigrationPolicy.self)
entityMapping.relationshipMappings = relationshipMappings
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as above

}

@nonobjc
internal func cs_resolveRelationshipNames() -> [String: (relationship: NSRelationshipDescription, versionHash: Data)] {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment above regarding cs_resolveAttributeNames

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] {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment above regarding mapAttributes

@@ -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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing out this typo. Personally I think it should be in a separate PR though


var map: [NSAttributeDescription: NSAttributeDescription] = [:]
XCTAssertNoThrow(map = try dst.mapAttributes(in: src))
XCTAssertEqual(map.count, 1)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my understanding, in this case mapAttributes() should throw. The new dst.bar should be completely unrelated to src.bar (dst.bar should be linked to a src.foo)


class Bar: CoreStoreObject {
var firstName = Value.Required<String>("firstName", initial: "", renamingIdentifier: "name")
var lastName = Value.Required<String>("lastName", initial: "", renamingIdentifier: "placeholder")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should never migrate successfully as there is no "placeholder" key in the source entity.

@iby
Copy link
Contributor Author

iby commented Jun 14, 2019

Hey, apologies for the delay, have been away. Let's get this rolling!

I'm not sure we have the same presumption of how renamingIdentifiers should work. Please take a look at my comments.

Also as much as possible, please give some examples of the use cases where the previous migration logic fails and which this PR resolves.

I'm sure we don't, otherwise this PR wouldn't exist! 😄 I've migrated an existing app to CoreStack from using native migrations, both, lightweight and ones employing mapping models. It turned out there were a few schemas where renaming identifiers were copied over and were not removed across different versions. Standard CoreData tools would gracefully handle such cases, while CoreStack would crash. I described this in #300 and made a #301 PR, which partly addressed the issue, but it turned out it's a little deeper.

Currently CoreStack assumes that renaming identifiers, if present, are correct and does explicit unwrapping. Instead, I believe, it should follow what CoreData does and check whether a renaming identifier matches an attribute or entity from a previous version and only then create a mapping. Otherwise, it should fallback to current attribute/entity name or throw an error if mapping cannot be created explaining why the migration failed, but never just crash. So, this is what this PR is about – it attempts to match CoreData's graceful handling of renamingIdentifiers for, both, attributes and entities.

It's hard to overlook issues with renaming identifiers when using dynamic schemas, but very easy when using xcdatamodel files. What's worse is once such a schema makes it into production there's no way back. This is why it's important to stick with the CoreData approach, but perhaps it would also make sense to log warnings in debug builds when renaming identifiers cannot be mapped.

If you're happy with that, I'll review the rest of the feedback.

@iby
Copy link
Contributor Author

iby commented Sep 13, 2019

@JohnEstropia still hoping we can look over this on some sad past-summer evening? 🍁😄 Would very much appreciate your input.

@JohnEstropia
Copy link
Owner

@ianbytchek Hi, I've been meaning to respond to this but simply keep forgetting. Sorry

It's hard to overlook issues with renaming identifiers when using dynamic schemas, but very easy when using xcdatamodel files.

I would recommend you use XcodeSchemaMappingProvider or even InferredSchemaMappingProvider instead. Those build on the foundations that standard Core Data migration provide by default.

CustomMappingProvider on the other hand was designed mainly for CoreStoreObject schemas, which do not support xcmappingmodel files or even NSEntityMigrationPolicy classes. The biggest point is that CoreStoreObject schema's are designed to be used with Progressive Migration, that is, the MigrationChain is explicitly defined for all target model versions. So regarding the handling of renamingIdentifiers, there is little benefit to simulating the default Core Data handling because the flow of transformations are known. On that same reasoning, since transformations are well-known and well-defined during compile time via SchemaHistory, there should never be "optional" mappings. I guess one thing we can improve here is to allow CustomMappingProvider only if the destination model uses CoreStoreObject entities.

I do understand that this is a pretty strict decision for a library, but first let me know if XcodeSchemaMappingProvider or InferredSchemaMappingProvider helps you.

What's worse is once such a schema makes it into production there's no way back. This is why it's important to stick with the CoreData approach, but perhaps it would also make sense to log warnings in debug builds when renaming identifiers cannot be mapped.

I'm not sure the problem will be avoided by just following Core Data's approach. Even after a schema makes it into production the migration can always be controlled in new versions. (If you do not have the schema for the old one you cannot migrate from it anyway)

The one thing we can possibly improve here is to provide a way to automatically create migration unit tests given the schema models.

@iby
Copy link
Contributor Author

iby commented Sep 13, 2019

If this the ideological thing then it's hard to argue. I'll give you that.

I do understand that this is a pretty strict decision for a library, but first let me know if XcodeSchemaMappingProvider or InferredSchemaMappingProvider helps you.

I replaced Xcode migrations with CustomSchemaMappingProvider-based ones, so no way to test this out now. At the moment I work around the described issues by providing custom transformers instead of CustomMapping.inferredTransformation, which would crashes, otherwise.

Xcode data models seemed, both, easier and more elegant back at the time, unlike the migration mappings. So, I kept the models, but moved to CustomMappingProvider, which are beyond awesome. These days and starting afresh I might have gone for the CoreStoreObject, but xcdatamodeld files are here to stay and since CoreStore already works alongside with Core Data, I still feel this PR has some rationale behind it. Especially the part with explicit unwrapping and throwing proper errors.

Going forward, I can keep the error handling part and we can take the rest into a separate PR if needed. As an Idea, CustomMapping.inferredTransformation can have an option for the default strict mode or to follow the Core Data behaviour in graceful mode.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants