Mastering Relationships in Core Data: Practical Application

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the previous article, ”Mastering Relationships in Core Data: Fundamentals” we explored the basic concepts and principles of relationships in Core Data. Building on that foundation, this article aims to share practical experience and techniques for handling relationships in Core Data. The goal is to assist developers in more effectively utilizing the relational features of the Core Data framework, thereby enhancing development flexibility and efficiency.

This article is intended for readers who already have some knowledge and practical experience with Core Data relationships, providing an advanced understanding and application perspective, rather than offering a comprehensive tutorial.

Optional

When defining entity attributes in the Xcode model editor, developers should differentiate between the Optional option in the editor and the Optional type in Swift, as they are not the same. In Core Data, the Optional option means that the corresponding SQLite field can accept NULL values. In contrast, the Optional type in Swift is a language-level feature that indicates a variable can be nil. In Core Data models, the use of these two types of Optional depends on the specific scenario and the developer’s needs, and they do not necessarily correspond directly.

In Core Data, if an attribute of a model is marked as Optional, it can be defined as Non-Optional in the corresponding Swift code. This approach offers more flexibility, allowing developers to decide whether to use Swift’s Optional type in the code based on the actual application context.

For more detailed information on the Optional values in Core Data, please read Ask Apple 2022 Q&A Related on Core Data (Part 2).

For instance, consider Item and Tag, two entities with a One-to-One relationship. When using Core Data with CloudKit, these relationships must be marked as Optional in the model editor. However, in practical application, if these two entity instances are always related to each other, meaning their relationship always has a value, they can be adjusted to be non-optional in the Swift code. The benefit of this adjustment is more convenient access to these properties in the code, eliminating the need for frequent unwrapping.

Item_Model

Tag_Model

The default code generated by Core Data is as follows:

Swift
extension Item {
    @NSManaged public var timestamp: Date?
    @NSManaged public var tag: Tag?  // Optional
}

extension Tag {
    @NSManaged public var name: String?
    @NSManaged public var item: Item? // Optional
}

However, you can adjust them to be non-optional based on the actual situation:

Swift
extension Item {
    @NSManaged public var timestamp: Date
    @NSManaged public var tag: Tag  // Non-Optional
}

extension Tag {
    @NSManaged public var name: String // None-Optional 
    @NSManaged public var item: Item // Non-Optional
}

This allows for more convenient data retrieval in the code, provided that developers ensure that the properties have been assigned values before they are accessed:

Swift
Text(item.tag.name)

Swiftifying Core Data Collection Types

When dealing with to-Many relationships in Core Data, especially those involving ordered relationships, adjusting their representation in Swift code can offer significant benefits.

For instance, consider changing tag to an ordered to-Many relationship tags:

Tags_Model

The default code generated by Core Data is as follows:

Swift
extension Item {
    @NSManaged public var timestamp: Date?
    @NSManaged public var tags: NSOrderedSet?
}

To enhance readability and usability of the code, we can consider converting the NSOrderedSet? type to Array<Tag>. This adjustment not only reduces the need for unwrapping but also aligns the tags property more closely with Swift language conventions, such as using subscripting and iterators.

Swift
extension Item {
    @NSManaged public var timestamp: Date?
    @NSManaged public var tags: Array<Tag>
}

After this adjustment, we can more conveniently manipulate these data in Swift, for example (as Array conforms to the RandomAccessCollection protocol):

Swift
ForEach(item.tags){ tag in
    Text(tag.name ?? "")
}

However, it’s worth noting that converting a non-ordered to-Many relationship to an Array type may not always be the best choice. This is mainly due to the intrinsic characteristics of non-ordered collections and their management in Core Data. In Core Data, non-ordered relationships are typically represented as NSSet, intuitively reflecting the unordered nature and uniqueness of the elements in the collection. Converting this to an Array type might cause a loss of these key characteristics at face value. Therefore, for non-ordered relationships, using Swift’s Set type is often a more appropriate choice.

For example, for the tags attribute of the Item entity, if it is a non-ordered, optional to-Many relationship, it can be represented in Swift as follows:

Swift
extension Item {
    @NSManaged public var timestamp: Date?
    @NSManaged public var tags: Set<Tag>
}

This approach maintains the unordered nature and uniqueness of the collection while aligning the code more closely with Swift usage habits, enhancing its readability.

Count

When dealing with to-Many relationships in Core Data, it’s often necessary to obtain the count of associated objects. While directly using the .count property is a common method, developers can also consider using a derived attribute (Derived Attribute) for a more efficient way to obtain this count.

For example, in the situation shown below, we have created a derived attribute named count for the TodoList entity. This allows developers to simply access todolist.count to directly obtain the number of items objects associated with the TodoList. This method makes retrieving the count of associated objects both intuitive and efficient.

Derived

Compared to directly calling the .count property of a relationship, using a derived attribute for counting is generally more efficient. This is because derived attributes employ a different counting mechanism—they calculate and save the count value when data is written, and use this pre-calculated value when data is read. This mechanism is particularly suited to scenarios where read operations significantly outnumber write operations.

However, an important limitation of derived attributes is that they can only count data that has been persisted. This means if there are data that have not yet been saved to persistent storage, i.e., in a transient state, these data will not be included in the count by the derived attribute. Therefore, when using derived attributes, developers need to be mindful of this limitation and ensure that their data handling logic takes this counting method into consideration.

For a deeper understanding of how to use derived attributes, it’s recommended to read “How to use Derived and Transient Properties in Core Data”, which provides a detailed introduction to the application of derived attributes.

Managing Non-Ordered to-Many Relationships

In many practical application scenarios, to-Many relationships are often non-ordered. This is especially evident when using Core Data with CloudKit, as it does not support ordered relationships.

When data is directly retrieved through relationship properties, as shown in the example code below, Core Data cannot guarantee the order of the returned data:

Swift
let tags = Array(items.tags)

In most cases, Core Data uses a SQLite database for data storage at the backend. In the database, unless a specific sort order is explicitly defined, the retrieval order of records is indeterminate.

Therefore, to ensure consistency when fetching non-ordered to-Many data, it is advised not to rely solely on direct use of relationship properties. Instead, create an NSFetchRequest that includes predicates and sort criteria to perform the query, as shown below:

Swift
func fetchTagsBy(item:Item) -> [Tag] {
    let request = NSFetchRequest<Tag>(entityName: "Tag")
    request.predicate = NSPredicate(format: "item = %@", item)
    request.sortDescriptors = [NSSortDescriptor(keyPath: \Tag.name, ascending: true)]
    return (try? viewContext.fetch(request)) ?? []
}

In SwiftUI development, it’s recommended to encapsulate the interface displaying to-Many data into a separate view and fetch data using @FetchRequest. This approach not only ensures the stability of the data retrieval order but also promptly responds to data changes, making view updates more efficient:

Swift
struct TagsList: View {
    @FetchRequest var tags: FetchedResults<Tag>
    init(item: Item) {
        let request = NSFetchRequest<Tag>(entityName: "Tag")
        request.predicate = NSPredicate(format: "item = %@", item.objectID) // Using NSManagedObject and NSManagedObjectID generates the same SQL commands
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Tag.name, ascending: true)]
        _tags = FetchRequest(fetchRequest: request)
    }

    var body: some View {
        List(tags) { tag in
            TagDetail(tag: tag)
        }
    }
}

struct TagDetail: View {
    @ObservedObject var tag: Tag
    var body: some View {
        Text(tag.name)
    }
}

Many-to-Many Relationships and Subqueries

In our previous article, we discussed how relationships can enhance query efficiency and expand querying capabilities in certain scenarios. Subqueries in Core Data are a prime example of this in action.

A subquery is an efficient querying technique within the Core Data framework, allowing developers to perform more complex queries on an existing set of results. This is particularly useful when dealing with complex data models, especially when filtering based on attributes of related objects.

The basic format of a subquery is as follows:

Swift
SUBQUERY(collection, $x, condition)
  • collection refers to the set to be queried, typically a to-many relationship property.
  • $x is a variable representing each element in the set (the name can be arbitrarily set).
  • condition is the criterion applied to each element in the collection.

For example, suppose we want to retrieve all Item instances that have at least one Tag with a name starting with “A”. The following NSPredicate expression can be used:

Swift
NSPredicate(format: "SUBQUERY(tags, $tag, $tag.name BEGINSWITH 'A').@count > 0")

The corresponding operation using Swift’s higher-order functions in memory would be:

Swift
let result = items.filter { item in
    item.tags.contains { tag in
        tag.name.hasPrefix("A")
    }
}

Subqueries are executed directly at the SQLite level, meaning they are more efficient both in terms of performance and memory usage compared to filtering in memory. Additionally, it is recommended to perform all filtering and sorting operations at the SQLite level, using well-designed predicates and sorting conditions. This approach not only improves data processing efficiency but also helps reduce the memory load on the application, especially when dealing with large data sets.

What’s Next

In this article, we have explored a series of techniques for applying relationships in Core Data within real-world development scenarios. Indeed, once developers grasp the fundamental theories and internal mechanisms of relationships, they can continuously summarize and discover methods and experiences that are more suitable for their own projects.

The upcoming article will concentrate on SwiftData, the successor framework to Core Data. We will explore the changes in how SwiftData manages data relationships and critically assess the applicability of these changes. Particular attention will be given to how potential performance issues in relational operations can be effectively avoided in its initial version.

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