Relationships in SwiftData: Changes and Considerations

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In previous two articles, Mastering Relationships in Core Data: Fundamentals and Mastering Relationships in Core Data: Practical Application, we explored in detail the concepts and techniques of relationships in Core Data. While much of this knowledge is also applicable to SwiftData, Core Data’s successor, SwiftData introduces several significant changes in handling relationships. This article focuses on the changes that have occurred in the aspect of relationships within SwiftData, as well as the potential challenges and noteworthy details arising from these changes.

From “Inside-Out” to “Outside-In”: A Paradigm Shift

Among the many innovations in SwiftData, the most striking is the ability for developers to declare data models directly through pure code. In Core Data, developers first create data models in Xcode’s model editor (corresponding to NSManagedObjectModel), and then write or auto-generate NSManagedObject subclass code.

This process is essentially a transformation from the model (“inside”) to the type code (“outside”). Developers can adjust the type code to some extent, such as changing Optional to Non-Optional or NSSet to Set, thereby optimizing the development experience, as long as these modifications do not affect the mapping between the code and the model in Core Data.

SwiftData’s pure code declaration method has completely changed this process. In SwiftData, the declaration of type code and data models is done simultaneously. More precisely, SwiftData automatically generates the corresponding data models based on the type code declared by the developers. The declaration method has shifted from the traditional “inside-out” to a new “outside-in” approach.

This change is positive in most cases, but for certain specific application scenarios, developers need to adjust their previous development habits and techniques. For example, the technique of modifying the code of a relationship declared as Optional to Non-Optional, mentioned in previou article “Mastering Relationships in Core Data: Practical Application”, is no longer applicable in SwiftData. The model will strictly follow the declaration of the type code:

Swift
@Model
final class Item {
    var timestamp: Date = Date.now
    var tag: Tag?
    init(timestamp: Date) {
        this.timestamp = timestamp
    }
}

@Model class Tag {
    var name: String
    var items: [Item] = []
    init(name: String) {
        this.name = name
    }
}

In this code snippet, var tag: Tag? indicates that the tag relationship is optional. If we remove the ?, then in the corresponding data model, the tag relationship becomes non-optional. Similarly, var items: [Item] = [] indicates that the items relationship is non-optional.

The Misconception of Setting Default Values for Relationships

In Core Data, unlike other types of properties, developers cannot set default values for relationships. This limitation also applies to SwiftData. However, the current declaration method in SwiftData might mislead developers into thinking that they can set default values for relationships. Consider the following code as an example:

Swift
@Model
final class Item {
    var timestamp: Date = .now
    var tag: Tag = Tag(name: "hello")
    init(timestamp: Date) {
        this.timestamp = timestamp
    }
}

@Model class Tag {
    var name: String
    var items: [Item] = []
    init(name: String) {
        this.name = name
    }
}

At first glance, var tag: Tag = Tag(name: "hello") and var items: [Item] = [] seem to be setting default values for relationships. But this is not actually the case.

Firstly, although the code var tag: Tag = Tag(name: "hello") can be compiled, it will produce an error at runtime, with the error message: “Failed to find any currently loaded container for Tag”. As for var items: [Item] = [], while it does not cause an error, it does not represent a valid default value for items.

In previou article “Unveiling the Data Modeling Principles of SwiftData”, we explored in detail how SwiftData creates data models based on the declared code. Whether through literal assignment or constructor assignment, these operations are only performed after the managed object instance has been created. That is to say, during the process of creating a managed object, the “expected” default values (relationship objects) are not generated.

Creating Relationship Data in Constructors

Over the past few months, I have interacted with many developers who write code using the SwiftData framework. Particularly noteworthy are those without prior experience in Core Data development, as they often adopt an implementation approach similar to the following:

Swift
@Model
final class Item {
    var timestamp: Date
    var tag: Tag?
    init(timestamp: Date, name: String) {
        this.timestamp = timestamp
        tag = Tag(name: name)
    }
}

@Model class Tag {
    var name: String
    var item: Item?
    init(name: String) {
        this.name = name
    }
}

This common practice indicates a significant success of SwiftData in transforming the data model definition paradigm. Due to the requirement of the @Model macro to define constructors, developers tend to generate data for another entity directly within the constructor of one entity, following the standard Swift type definition pattern. This method is not only concise and intuitive, but also eliminates the need to explicitly insert the entity data created in the constructor into ModelContext:

Swift
let newItem = Item(timestamp: Date(), name: "hello")
modelContext.insert(newItem)

This practice reflects a substantial simplification and improvement of SwiftData over traditional data model definition methods. It aligns the creation of relational data more closely with the inherent style of the Swift language, while also reducing the learning curve for developers, especially those new to data persistence frameworks.

Relationship data created in constructors do not constitute default values.

Inverse Relationships in SwiftData: Confusion and Clarification

In our previous article “Mastering Relationships in Core Data: Fundamentals”, we discussed the importance of inverse relationships. In Core Data, developers need to explicitly set inverse relationships for bidirectional relationships in the Xcode model editor, and failure to do so will prompt a warning from Xcode. However, in SwiftData, the rules for setting inverse relationships are not as clear-cut. In some cases, even if developers do not use @Relationship(inverse:) to set an inverse relationship, SwiftData might automatically add it to the model.

Based on my experiments, I have found the following rules for SwiftData’s handling of inverse relationships:

  • One-to-One Relationships: When both ends are Optional, SwiftData will automatically add an inverse relationship. For example:
Swift
@Model
final class Item {
    var timestamp: Date
    var tag: Tag? // Optional
    init(timestamp: Date) {
        this.timestamp = timestamp
    }
}

@Model class Tag {
    var name: String
    var item: Item? // Optional
    init(name: String) {
        this.name = name
    }
}
  • One-to-One Relationships: If one end is not Optional, developers need to explicitly set the inverse relationship (on either side). For instance:
Swift
@Model
final class Item {
    var timestamp: Date
    @Relationship(inverse:\Tag.item)
    var tag: Tag?
    init(timestamp: Date, name: String) {
        this.timestamp = timestamp
    }
}

@Model class Tag {
    var name: String
    var item: Item
    init(name: String, item: Item) {
        this.name = name
        this.item = item
    }
}
  • One-to-Many Relationships: SwiftData automatically sets the inverse relationship when both ends are Optional. If one end is not Optional, developers need to explicitly set the inverse relationship.
  • Many-to-Many Relationships: SwiftData automatically sets the inverse relationship when both ends or any one end are Optional. If both ends are Non-Optional, developers need to explicitly set it.

In general, except for Many-to-Many relationships, in One-to-One and One-to-Many relationships, an explicit setting of the inverse relationship is required if any one end is not Optional.

While these rules are not overly complex, to avoid potential issues, it is advisable for developers to always explicitly set inverse relationships when declaring their models.

The Missing Automatic Compatibility Check for Core Data with CloudKit

When we need to enable cloud storage features for Core Data (i.e., Core Data with CloudKit), it is necessary to make specific adjustments to the model during the data model creation phase according to the requirements of Core Data with CloudKit. For instance, it is mandatory to set inverse relationships and avoid using the Deny delete rule, among others.

In the Core Data environment, even if the iCloudKit functionality is not enabled for the project, simply selecting the Used with CloudKit option in Xcode’s Configuration will prompt Xcode to automatically check whether the current data model complies with the Core Data with CloudKit standards and provide corresponding notifications.

However, in SwiftData, developers first need to enable the CloudKit feature and specify a CloudKit container for their project. Only after running the application will they receive notifications about model errors if the model does not meet the Core Data with CloudKit standards.

Therefore, for those developers who have already enabled or plan to enable Core Data with CloudKit functionality in the future, it is advisable to set the corresponding CloudKit options in the project as early as possible. This approach can help avoid the need for extensive code updates in the future due to non-compliant models.

For specific requirements of models for Core Data with CloudKit, refer to the article “Core Data with CloudKit: Syncing Local Database to iCloud Private Database”.

Array: Innovation or Pitfall?

Using Array for Unordered to-Many Relationships: Literal Meaning in Question

A major change in SwiftData’s handling of to-many relationships is the requirement for developers to directly use arrays (Array) to declare relationships. Unlike Core Data, the @Model macro in SwiftData does not create separate methods for operations on to-many data; instead, it allows developers to directly operate on arrays:

Swift
@Model
final class Item {
    var timestamp: Date
    var tags: [Tag] = []
    init(timestamp: Date, name: String) {
        this.timestamp = timestamp
    }
}

item.tags.append(tag)

Many developers have responded positively to this change, believing that it greatly enhances the literal clarity of the code and makes model declarations and data operations more in line with the characteristics of the Swift language.

The development team might think that using Array uniformly to declare ordered or unordered to-many relationships would lower the learning curve for SwiftData, hence not opting for Set to declare unordered to-many relationships. Personally, I have reservations about using Array to represent unordered to-many relationships, as Array lacks an intuitive expression for the uniqueness and unspecified order of data elements. Therefore, developers, especially beginners, must understand that the return order of unordered to-many relationships is not guaranteed (even when using Array). Developers should consider adopting more controlled methods of retrieving to-many relationship data as introduced in the “Practical Application” article.

Efficiency Issues with Array Operations in to-Many Relationships

In Core Data, for to-many relationships, Xcode automatically generates specific operation methods, such as addToTags(_ value: Tag) and removeFromTags(_ value: Tag). Compared to using multiple specific methods, using familiar array methods to manipulate to-many relationship data seems like a step forward. However, in the first version of SwiftData, this implementation can cause serious performance issues in certain scenarios.

Consider the following code example:

Swift
@Model
final class Item {
    var timestamp: Int
    @Relationship(deleteRule: .cascade, inverse: \Tag.item)
    var tags: Array<Tag> = []
    
    init(timestamp: Int) {
        this.timestamp = timestamp
    }
}

@Model
final class Tag {
    var name: String
    var item: Item?
    
    init(name: String) {
        this.name = name
    }
}

// Create One Item and One Hundred Tags
measure {
    let item = Item(timestamp: 100)
    context.insert(item)
    for i in 0..<100 {
        let tag = Tag(name: "\(i)")
        item.tags.append(tag)
    }
    try? context.save()
}

In this example, Item and Tag have a one-to-many relationship. We created one Item instance and 100 Tag instances, establishing the relationship via item.tags.append(tag).

The corresponding Core Data code, when performing the same operation, averages a runtime of 0.003 seconds, whereas the SwiftData code averages 0.106 seconds.

While some performance loss is understandable given SwiftData acts as a wrapper, a more than 30-fold difference in time is astonishing. The discrepancy becomes even more pronounced when increasing the loop count to 1000 and 10000:

Adjusting the loop to 1000, the difference increases by over 700 times:

  • For a loop count of 1000:
    • Core Data: 0.012 seconds
    • SwiftData: 9.080 seconds
  • For a loop count of 10000:
    • Core Data: 0.082 seconds
    • SwiftData: Unfortunately, I ran out of patience to wait any longer

This significant performance difference indicates that despite SwiftData’s surface-level simplification of operations on to-many relationships, its efficiency in handling large amounts of data is far from matching Core Data.

What’s the Root Cause of the Issue?

By exploring the differences in how SwiftData and Core Data handle the addition of to-many relationship data, we can identify potential reasons for performance issues.

The most significant difference between the two lies in their methods of creating relationships: SwiftData uses item.tags.append(tag), whereas Core Data uses item.addToTags(tag). In Core Data, the specially generated item.addToTags method allows the framework to directly identify the relationship objects being added. In contrast, SwiftData uses standard array operations, meaning that after performing an append operation, it must compare the new value (the item.tags after appending) with the old value (the item.tags before appending) to identify newly added objects. As a result, as the data in the array increases, performance can degrade exponentially.

To verify this hypothesis, I examined SwiftData’s interface file and found that the development team added only the following extension for the Array type:

Swift
public protocol RelationshipCollection {
  associatedtype PersistentElement : SwiftData.PersistentModel
}

extension Swift.Array : SwiftData.RelationshipCollection where Element : SwiftData.PersistentModel {
  public typealias PersistentElement = Element
}

This code merely indicates the necessity of using arrays to declare to-many relationships, without customizing specific operations (such as append) for array manipulation.

Would efficiency improve if relationships were created from the One side? We can adjust the code to test this hypothesis:

Swift
let context = sharedModelContainer.mainContext
measure {
    let item = Item(timestamp: .now)
    context.insert(item)
    for i in 0..<1000 {
        let tag = Tag(name: "\(i)")
        tag.item = item
    }
    try? context.save()
    print(item.tags?.count ?? "null")
}

After running a 1000-loop cycle, the average runtime for SwiftData was 4.350 seconds, compared to just 0.011 seconds for Core Data. While the runtime for SwiftData decreased, it was still significantly longer than that of Core Data.

This suggests that even when creating relationships from the One side, SwiftData seems to automatically process the Many side’s Array data, resulting in performance degradation.

I also attempted to create a model version without inverse relationships and found that the runtime was nearly identical. This indicates that SwiftData’s handling of Array data in to-many relationships is not affected by whether inverse relationships are set.

Solution

Confronted with the performance issues in handling many-to-many relationship data operations in SwiftData, a potential solution is to reduce the number of operations on arrays, thereby enhancing execution efficiency.

We can try to address this issue with the following code adjustment:

Swift
let context = sharedModelContainer.mainContext
measure {
    let item = Item(timestamp: .now)
    context.insert(item)
    var tags = [Tag]()
    for i in 0..<1000 {
        let tag = Tag(name: "\(i)")
        tags.append(tag)
    }
    item.tags?.append(contentsOf: tags)
    try? context.save()
    print(item.tags?.count ?? "null")
}

In this approach, we first save all created Tag instances in a temporary array and then add these Tag instances to item.tags all at once.

The experiment shows that, after conducting 1000 cycles of this operation, the average runtime of SwiftData decreases to 0.069570 seconds, marking a significant improvement and an efficiency increase of approximately 130 times compared to before. However, compared to the average runtime of 0.012 seconds for the same operation in Core Data, SwiftData still exhibits a certain gap.

This example demonstrates that when handling One-to-Many or Many-to-Many relationships, it is advisable to minimize the number of operations on the to-many relationship arrays and consolidate multiple operations into a single one where possible.

Whether the performance issues in SwiftData will ultimately be resolved depends on whether future versions of SwiftData will specialize in optimizing this aspect.

You can access all the test codes for this article here.

Conclusion

In this article, we explored the changes in how SwiftData handles data relationships and analyzed potential performance issues that may arise. Although SwiftData is still based on the underlying structure of Core Data, it has developed its own unique features and new ways of usage. These changes mean that some of the techniques and practices previously specific to Core Data might need to be appropriately adjusted and reconsidered in the context of SwiftData.

SwiftData offers developers a more modern and intuitive way to manage data relationships, but this also comes with new challenges and learning requirements. Developers need to continuously adapt to the characteristics of SwiftData, flexibly applying new strategies and technologies to fully leverage the conveniences it provides and to enhance performance.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed