肘子的 Swift 记事本

How to Deep Copy NSManagedObject in Core Data

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Deep copying an NSManagedObject means creating a manageable duplicate of an NSManagedObject (managed object) that includes all the data involved in every relational hierarchy of that managed object.

Although Core Data is a powerful object graph management framework, it doesn’t provide a direct method for copying managed objects. If developers want to create a duplicate of a managed object, the only feasible way is to write specific code that reads the properties of the original object and assigns them to a new object. This method is effective when the structure of the managed object is simple. However, once the structure becomes complex and relationships become numerous, the amount of code increases significantly and is prone to errors.

Developers have been looking for a convenient and universal tool to solve the deep copy issue for years, but until now, there hasn’t been a widely accepted solution.

I also encountered this problem while developing an app, needing to deep copy a managed object with a complex structure and extensive relational chains. Considering that I might face similar situations in the future, I decided to write a piece of code that is simple to use and widely applicable for my own use.

In this article, we will explore the technical challenges of deep copying NSManagedObject in Core Data, the thought process of solving these issues, and introduce a tool I wrote - MOCloner.

Challenges in Deep Copying NSManagedObject

Complex Relationship Structures

The following image is a part of the data model diagram for Health Notes. Although only a portion of the model relationships is selected, it nearly covers all types of relationships, including one-to-one, one-to-many, and many-to-many.

When copying a Note object, it involves hundreds or thousands of other objects in the relational chain. Achieving a fast and accurate deep copy of all data is quite challenging.

image-20211112143836634

Selective Copying

Sometimes when deep copying, we might not need to copy all the data in every relationship level. We may want to ignore a particular branch at the n+1 level in the nth level.

Or, when copying a certain property (optional or with a default value) of a managed object, we might choose not to copy its content.

All these tasks should ideally be handled during the deep copy process.

Data Validity

Some properties in managed objects have uniqueness or timeliness, which need special treatment in deep copying.

For example:

  • In the above diagram, the type of Note’s id is UUID. During deep copying, the original content should not be copied, and a new data should be created for the new object.
  • The NoteID in Item should correspond to the id of Note. How to maintain this consistency during copying?
  • The createDate in ItemDate should be the time of record creation. How to set it to the date of deep copying?

If similar issues can’t be handled during deep copying, adjusting them after copying will be challenging when dealing with large data volumes.

Inverse Many-to-Many Relationships

In the diagram above, Tag and Memo have a many-to-many relationship. When an inverse many-to-many situation (like Tag) appears in a relationship chain, it needs to be handled with extra caution. From a business logic perspective, Tag does not belong to a specific branch of a Note, which has always been a difficult problem in Core Data data synchronization.

Solution Approach for Deep Copying

Despite the various challenges, it is still possible to address them using the numerous tools provided by Core Data.

Making Good Use of Description

In Xcode, when using the Data Model Editor to create a data model, it is compiled into a .momd file and saved in the Bundle. When creating NSPersistentContainer, NSManagedObjectModel uses this file to translate the model definition into implementation. Developers can access various Descriptions provided by Core Data to get the necessary information.

The most commonly used Description might be NSPersistentStoreDescription, from which you can get Config or set iCloud options (for more information, refer to Mastering Core Data Stack).

Other Descriptions include, but are not limited to:

  • NSEntityDescription - Description of the entity.
  • NSRelationshipDescription - Description of the entity relationships.
  • NSAttributeDescription - Description of the entity attributes.
  • NSFetchIndexDescription - Description of the indexes.
  • NSDerivedAttributeDescription - Description of derived attributes.

The following code creates a new object with the same structure as a given managed object using NSEntityDescription:

Swift
guard let context = originalObject.managedObjectContext else {
    throw CloneNSManagedObjectError.contextError
}

// create clone NSManagedObject
guard let entityName = originalObject.entity.name else {
    throw CloneNSManagedObjectError.entityNameError
}
let cloneObject = NSEntityDescription.insertNewObject(
    forEntityName: entityName,
    into: context
)

To get all property descriptions of the managed object through NSAttributeDescription:

Swift
let attributes = originalObject.entity.attributesByName
for (attributeName, attributeDescription) in attributes {
    ...
}

To traverse all relationship descriptions of the managed object using NSRelationshipDescription:

Swift
let relationships = originalObject.entity.relationshipsByName

for (relationshipName, relationshipDescription) in relationships {
    ...
}

To get the entity corresponding to the inverse relationship description:

Swift
let inverseEntity = relationshipDescription.inverseRelationship?.entity

These Descriptions are the cornerstone for developing generic code for deep copying NSManagedObject.

Using userinfo to Pass Information

To address issues like selective copying and data validity mentioned above, it’s necessary to provide sufficient information during deep copying.

Since this information may be distributed across various levels of the relationship chain, the most direct and effective way is to add corresponding content in the User Info provided by Xcode’s data model editor.

image-20211112163510728

Every developer who has used Xcode’s data model editor should have seen the User Info input box on the right side. Through this box, we can set information for Entity, Attribute, and Relationship and extract it from the corresponding Description.

The following code checks if there is an exclusion flag in the userinfo of the Attribute:

Swift
if let userInfo = attributeDescription.userInfo {
    // Check if the "exclude" flag is added to this attribute
    // Only determine whether the Key is "exclude" or not, do not care about the Value
    if userInfo[config.exclude] != nil {
        if attributeDescription.isOptional || attributeDescription.defaultValue != nil {
            continue
        } else {
            throw CloneNSManagedObjectError.attributeExcludeError
        }
    }
}

The following code creates a new UUID for Attributes with rebuild : uuid flag in userinfo (of type UUID):

Swift
if let action = userInfo[config.rebuild] as? String {
    switch action {
    case "uuid":
        if attributeDescription.attributeType == NSAttributeType.UUIDAttributeType {
            newValue = UUID()
        } else {
            throw CloneNSManagedObjectError.uuidTypeError
        }
    ...
    default:
        break
    }
}

setPrimitiveValue and setValue

In Core Data development, setPrimitiveValue is often used in various situations. For example, it’s used in awakeFromInsert to set initial values for properties, and in willSave to check the validity of property values. Especially when we cannot directly call properties of the managed object instance, using setPrimitiveValue can conveniently set the value using the attribute name.

Swift
for (attributeName, attributeDescription) in attributes {
    var newValue = originalObject.primitiveValue(forKey: attributeName)
    cloneObject.setPrimitiveValue(newValue, forKey: attributeName)
}

Since setPrimitiveValue accesses the managed object’s raw value (bypassing the snapshot), it is more efficient and does not trigger KVO observation.

However, setPrimitiveValue also has its drawbacks—it does not automatically handle inverse relationships. When using it to set relationship content, work must be done on both sides of the relationship, which can significantly increase the amount of code.

For managed object instances, in most cases, Core Data generated relationship management methods are usually directly used for relationship operations, for example:

Swift
@objc(addItemsObject:)
@NSManaged public func addToItems(_ value: Item)

@objc(removeItemsObject:)
@NSManaged public func removeFromItems(_ value: Item)

@objc(addItems:)
@NSManaged public func addToItems(_ values: NSSet)

@objc(removeItems:)
@NSManaged public func removeFromItems(_ values: NSSet)
// Note and Item have a one-to-many relationship
let note = Note(context: viewContext)
let item = Item(context: viewContext)
note.addToItems(item)
item.note = note

In generic deep copy code, we cannot directly use these system-provided methods, but we can use setValue to set relationship data.

setValue will internally find the corresponding Setter to manage the bidirectional relationship.

Below is the code for setting to-one relationships:

Swift
if !relationshipDescription.isToMany,
   let originalToOneObject = originalObject.primitiveValue(forKey: relationshipName) as? NSManagedObject {
    let newToOneObject = try cloneNSMangedObject(
        originalToOneObject,
        parentObject: originalObject,
        parentCloneObject: cloneObject,
        excludedRelationshipNames: passingExclusionList ? excludedRelationshipNames : [],
        saveBeforeReturn: false,
        root: false,
        config: config
    )
    cloneObject.setValue(newToOneObject, forKey: relationshipName)
}

NSSet and NSOrderedSet

In Core Data, to-many relationships are represented in the generated NSManagedObject Subclass code as NSSet?. However, if the to-many relationship is set to ordered, its type changes to NSOrderedSet?.

image-20211112184857192

By checking NSRelationshipDescription’s isOrdered, you can select the correct corresponding type. For example:

Swift
if relationshipDescription.isOrdered {
    if let originalToManyObjects = (originalObject.primitiveValue(forKey: relationshipName) as? NSOrderedSet) {
        for needToCloneObject in originalToManyObjects {
            if let object = needToCloneObject as? NSManagedObject {
                let newObject = try cloneNSMangedObject(
                    object,
                    parentObject: originalObject,
                    parentCloneObject: cloneObject,
                    excludedRelationshipNames: passingExclusionList ? excludedRelationshipNames : [],
                    saveBeforeReturn: false,
                    root: false,
                    config: config
                )
                newToManyObjects.append(newObject)
            }
        }
    }
}

Handling Inverse Many-to-Many Relationships

When moving down the relationship chain, if an inverse relationship of an entity is many-to-many, it creates a tricky situation during deep copying—entities with inverse many-to-many relationships serve the entire forward relationship tree.

For example, in the previous diagram of Memo and Tag, a memo can correspond to multiple tags, and a tag can correspond to multiple memos. If we deep copy from Note down to Memo and continue to copy Tag, it would contradict the original design intent of Tag.

The solution is, when encountering an entity A in the relationship chain with an inverse many-to-many relationship, to no longer continue deep copying downwards. Instead, add the newly copied managed object to the relationship with entity A, fulfilling the data model’s design intention.

image-20211112192815648

Swift
if let inverseRelDesc = relationshipDescription.inverseRelationship, inverseRelDesc.isToMany {
    let relationshipObjects = originalObject.primitiveValue(forKey: relationshipName)
    cloneObject.setValue(relationshipObjects, forKey: relationshipName)
}

Deep Copying with MOCloner

Integrating the above approach, I wrote a library for deep copying NSManagedObject in Core Data — MOCloner.

MOCloner Overview

MOCloner is a small library designed to achieve customizable deep copying of NSManagedObject. It supports one-to-one, one-to-many, and many-to-many relationships. In addition to faithful copying of original data, it also provides selective copying and the generation of new values during the copy.

Basic Demonstration

Creating a deep copy of the Note in the above diagram:

Swift
let cloneNote = try! MOCloner().clone(object: note) as! Note

Deep copying from the middle of the relationship chain downwards (not copying the upper part of the chain):

Swift
// Add the names of relationships to ignore in excludedRelationshipNames
let cloneItem = try! MOCloner().clone(object: item, excludedRelationshipNames: ["note"]) as! Item

Customization

MOCloner uses key-value pairs added in Xcode’s Data Model Editor’s User Info for customizing the deep copy process. Currently, it supports the following commands:

  • exclude

    This key can be set in Attribute or Relationship. The presence of the exclude key, regardless of its value, will enable the exclusion logic.

    When set in Attribute’s userinfo, deep copying will not copy the original object’s attribute value (requires the Attribute to be Optional or have a Default value).

    When set in Relationship’s userinfo, deep copying will ignore all relationships and data under this relationship branch.

    For convenience in situations not suitable for setting in userinfo (e.g., deep copying from the middle of a relationship chain), you can also add the names of relationships to exclude in the excludedRelationshipNames parameter (as in Basic Demonstration 2).

image-20211112200648882

  • rebuild

    Used to dynamically generate new data during deep copying. Only for setting Attributes. Currently supports two values: uuid and now.

    uuid: For Attributes of type UUID, creates a new UUID during deep copying.

    now: For Attributes of type Date, sets the attribute to the current date (Date.now) during deep copying.

image-20211112201348978

  • followParent

    A simplified version of Derived. Only for setting Attributes. Allows specifying an Attribute in an entity lower in the relationship chain to get the value of a corresponding Attribute from a managed object instance higher in the chain (requires both Attributes to be of the same type). In the diagram below, Item’s noteID will get the value of Note’s id.

image-20211112205856380

  • withoutParent

    Used only in conjunction with followParent. Addresses situations when deep copying from the middle of a relationship chain, where followParent is set but the ParentObject cannot be obtained.

    When withoutParent is keep, it maintains the original value of the object being copied.

    When withoutParent is blank, it leaves the value unset (requires the Attribute to be Optional or have a Default value).

image-20211112210330127

If the key names in userinfo conflict with those already used in your project, you can customize them using MOClonerUserInfoKeyConfig.

Swift
let moConfig = MOCloner.MOClonerUserInfoKeyConfig(
    rebuild: "newRebuild", // new Key Name
    followParent: "followParent",
    withoutParent: "withoutParent",
    exclude: "exclude"
)

let cloneNote = try cloner.clone(object: note, config: moConfig) as! Note

System Requirements

MOCloner requires macOS 10.13, iOS 11, tvOS 11, watchOS 4, or higher.

Installation

MOCloner is distributed using Swift Package Manager. To use it in another Swift package, add it as a dependency in your Package.swift.

Swift
let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/fatbobman/MOCloner.git", from: "0.1.0")
    ],
    ...
)

To use MOCloner in an application, add it to your project using Xcode’s File > Add Packages…

Swift
import MOCloner

Given that MOCloner is just a few hundred lines of code, you can also copy the code directly into your project for use.

Considerations When Using MOCloner

Perform in a Private Context

When deep copying involves a large amount of data, it’s advisable to perform the operation in a private context to avoid occupying the main thread.

It’s recommended to use NSManagedObjectID for data transfer before and after the deep copy operation.

Memory Usage

Deep copying managed objects that involve a large amount of relational data may result in significant memory usage. This is particularly evident when dealing with binary type data (such as saving large amounts of image data in SQLite). Consider the following methods to control memory usage:

  • Temporarily exclude attributes or relationships that consume a lot of memory during deep copying. After deep copying, add them one by one using other code.
  • When deep copying multiple managed objects, consider performing them one by one using performBackgroundTask.

Version and Support

MOCloner is licensed under the MIT license, allowing you free usage in your projects. However, please note that MOCloner does not come with any official support channels.

Core Data offers a wide range of functionalities and options, enabling developers to create a vast array of different relationship graphs. MOCloner has been tested only in a subset of these scenarios. Therefore, before you start preparing to use MOCloner in your project, it is strongly recommended that you spend some time getting familiar with its implementation and conduct more unit tests to avoid any potential data error issues.

If you encounter any problems, errors, or wish to suggest improvements, please create Issues or Pull Requests on GitHub.

Conclusion

Deep copying NSManagedObject is not a common functionality requirement. However, having an easy-to-use solution like MOCloner might inspire you to try new design ideas in your Core Data projects.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways