肘子的 Swift 记事本

SwiftDataKit: Unleashing Advanced Core Data Features in SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

As the successor of Core Data, the brand new SwiftData framework was officially released at WWDC 2023. SwiftData is expected to become the primary object graph management and data persistence solution in the Apple ecosystem for a long time to come, providing services and support for developers. This article will discuss how developers can call the advanced features provided by Core Data in SwiftData without using the Core Data stack, in order to extend the current capabilities of SwiftData.

Current Difficulties Faced by SwiftData

Compared with Core Data, SwiftData has made comprehensive improvements in data model declaration, type safety, thread safety, and integration with SwiftUI. Among them, its data model creation mechanism based on Swift macros, type-safe predicate system, thread safety implemented through Actors, and close integration with the Observation framework make SwiftData more in line with the needs of modern programming.

However, due to insufficient preparation time, the current version of SwiftData is not yet able to implement some advanced features of Core Data. This brings some difficulties for developers who want to try SwiftData. Even if developers can accept setting the minimum deployment environment of the project to the latest system version (iOS 17, macOS 14, etc.), it is inevitable to synchronously create a set of data models and data stacks based on Core Data in the project to implement the missing features of SwiftData.

As a result, the advantages of SwiftData in data model declaration would be completely nullified, not only increasing the workload, but developers also have to figure out how to coordinate between the two data frameworks and model versions. Creating a parallel set of Core Data code in SwiftData projects merely for some advanced features is no doubt highly uneconomical.

Due to the aforementioned difficulties, I have been hesitant to make a decision to use SwiftData in a new project.

Approaches to Resolving Difficulties Faced by SwiftData

Although SwiftData differs greatly from Core Data in its presentation, its core foundation is still Core Data. Apple utilized new Swift language features and design philosophies aligned with contemporary programming styles to rebuild Core Data. This not only allowed SwiftData to inherit the stable traits of Core Data in data persistence, but also meant that some key components of SwiftData correspond to specific Core Data objects behind the scenes. If we can extract these objects and use them in a limited capacity under safe environments, Core Data’s advanced capabilities can be leveraged in SwiftData.

Through reflection capabilities (Mirror) offered by Swift, we can extract the required Core Data objects from some SwiftData components, for example, extracting NSManagedObject from PersistentModel, and NSManagedContext from ModelContext. Additionally, SwiftData’s PersistentIdentifier conforms to the Codable protocol, which enables us to convert between it and NSManagedObjectID.

SwiftDataKit

Based on the approaches discussed above, I developed the SwiftDataKit library, which allows developers to leverage the Core Data objects behind SwiftData components to accomplish functionalities not available in the current version.

For example, the following is a code snippet for extracting NSManagedObjectContext from ModelContext:

Swift
public extension ModelContext {
    // Computed property to access the underlying NSManagedObjectContext
    var managedObjectContext: NSManagedObjectContext? {
        guard let managedObjectContext = getMirrorChildValue(of: self, childName: "_nsContext") as? NSManagedObjectContext else {
            return nil
        }
        return managedObjectContext
    }

    // Computed property to access the NSPersistentStoreCoordinator
    var coordinator: NSPersistentStoreCoordinator? {
        managedObjectContext?.persistentStoreCoordinator
    }
}

func getMirrorChildValue(of object: Any, childName: String) -> Any? {
    guard let child = Mirror(reflecting: object).children.first(where: { $0.label == childName }) else {
        return nil
    }

    return child.value
}

Next, I will briefly introduce the usage and precautions of SwiftDataKit through several specific cases.

SwiftDataKit is an experimental library. Since the SwiftData API is still evolving rapidly, I suggest that only experienced developers who understand its implementation and explicit risks use it cautiously in specific scenarios.

Implementing Grouped Counting Using NSManagedObjectContext

In some scenarios, we need to group data and then count, such as counting the number of students born in different years.

Swift
@Model
class Student {
    var name: String
    var birthOfYear: Int

    init(name: String, birthOfYear: Int) {
        self.name = name
        self.birthOfYear = birthOfYear
    }
}

The new predicate system in SwiftData currently does not support grouped counting. The native approach is shown as follows:

Swift
func birthYearCountByQuery() -> [Int: Int] {
    let description = FetchDescriptor<Student>(sortBy: [.init(\Student.birthOfYear, order: .forward)])
    let students = (try? modelContext.fetch(description)) ?? []
    let result: [Int: Int] = students.reduce(into: [:]) { result, student in
        let count = result[student.birthOfYear, default: 0]
        result[student.birthOfYear] = count + 1
    }
    return result
}

Developers need to retrieve all data and perform grouping and statistical operations in memory. When dealing with large amounts of data, this method can have a significant impact on performance and memory usage.

With SwiftDataKit, we can directly use the NSManagedObjectContext underlying ModelContext and perform this operation on the SQLite on the database side by creating an NSExpressionDescription.

Swift
func birthYearCountByKit() -> [Int: Int] {
    let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Student")
    fetchRequest.propertiesToGroupBy = ["birthOfYear"]
    fetchRequest.sortDescriptors = [NSSortDescriptor(key: "birthOfYear", ascending: true)]
    fetchRequest.resultType = .dictionaryResultType
    let expressDescription = NSExpressionDescription()
    expressDescription.resultType = .integer64
    expressDescription.name = "count"
    let year = NSExpression(forKeyPath: "birthOfYear")
    let express = NSExpression(forFunction: "count:", arguments: [year])
    expressDescription.expression = express
    fetchRequest.propertiesToFetch = ["birthOfYear", expressDescription]
    // modelContext.managedObjectContext, use NSManagedObjectContext directly
    let fetchResult = (try? modelContext.managedObjectContext?.fetch(fetchRequest) as? [[String: Any]]) ?? []
    let result: [Int: Int] = fetchResult.reduce(into: [:]) { result, element in
        result[element["birthOfYear"] as! Int] = (element["count"] as! Int?) ?? 0
    }
    return result
}

In a test of 10,000 data, the implementation method based on SwiftDataKit is 4 to 5 times more efficient than the native method, and also uses much less memory.

When using SwiftDataKit, there are a few things to keep in mind:

  • Although it is not necessary to create a Core Data version of the data model type, entities and attributes can be accessed using strings. By default, the model type name in SwiftData corresponds to the entity name, and variable names correspond to attribute names.
  • It is not recommended to use methods like setPrimitiveValue(value:, forKey:) and value(forKey:) to read and write NSManagedObject properties, as they lack compile-time checking.
  • SwiftData uses Actor to ensure that data operations are performed in the thread where ModelContext exists, so there is no need to use context.perform method to avoid thread issues within Actor methods.
Swift
@ModelActor
actor StudentHandler {
    func birthYearCountByKit() -> [Int: Int] {
        ...
        // No need to use modelContext.managedObjectContext.perform { ... }
    }

    func birthYearCountByQuery() -> [Int: Int] {
        ...
    }
}
  • Unlike Core Data, which allows for the explicit creation of private contexts (running on non-main threads), the thread bound to an actor instance created via @ModelActor is determined by the context at creation time (_inheritActorContext).

Convert PersistentModel to NSManagedObject and implement subqueries.

In Core Data, developers can use subquery predicates to implement nested queries directly on the SQLite side, which is an essential feature for certain scenarios.

For example, we have the following data model definition:

Swift
@Model
class ArticleCollection {
    var name: String
    @Relationship(deleteRule: .nullify)
    var articles: [Article]
    init(name: String, articles: [Article] = []) {
        self.name = name
        self.articles = articles
    }
}

@Model
class Article {
    var name: String
    @Relationship(deleteRule: .nullify)
    var category: Category?
    @Relationship(deleteRule: .nullify)
    var collection: ArticleCollection?
    init(name: String, category: Category? = nil, collection: ArticleCollection? = nil) {
        self.name = name
        self.category = category
        self.collection = collection
    }
}

@Model
class Category {
    var name: String
    @Relationship(deleteRule: .nullify)
    var articles: [Article]
    init(name: String, articles: [Article] = []) {
        self.name = name
        self.articles = articles
    }

    enum Name: String, CaseIterable {
        case tech, health, travel
    }
}

In this model relationship (ArticleCollection <-->> Article <<--> Category), we want to query how many Articles belonging to a specific Category are in any ArticleCollection.

Currently, using the native methods of SwiftData looks like this:

Swift
func getCollectCountByCategoryByQuery(categoryName: String) -> Int {
    guard let category = getCategory(by: categoryName) else {
        fatalError("Can't get tag by name:\(categoryName)")
    }
    let description = FetchDescriptor<ArticleCollection>()
    let collections = (try? modelContext.fetch(description)) ?? []
    let count = collections.filter { collection in
        !(collection.articles).filter { article in
            article.category == category
        }.isEmpty
    }.count
    return count
}

Similar to the previous method, it is necessary to obtain all data in memory for filtering and analysis.

By converting PersistentModel to NSManagedObject, we can improve efficiency with predicates that contain subqueries:

Swift
func getCollectCountByCategoryByKit(categoryName: String) -> Int {
    guard let category = getCategory(by: categoryName) else {
        fatalError("Can't get tag by name:\(categoryName)")
    }
    let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "ArticleCollection")
    // get NSManagedObject by category.managedObject
    guard let categoryObject = category.managedObject else {
        fatalError("can't get managedObject from \(category)")
    }
    // use NSManagedObject in Predicate
    let predicate = NSPredicate(format: "SUBQUERY(articles,$article,$article.category == %@).@count > 0", categoryObject)
    fetchRequest.predicate = predicate
    return (try? modelContext.managedObjectContext?.count(for: fetchRequest)) ?? 0
}

// fetch category by name
func getCategory(by name: String) -> Category? {
    let predicate = #Predicate<Category> {
        $0.name == name
    }
    let categoryDescription = FetchDescriptor<Category>(predicate: predicate)
    return try? modelContext.fetch(categoryDescription).first
}

In this example, predicates are created and data is retrieved based on the name of the category. Typically, PersistentIdentifier is also used to ensure safe transmission between different ModelContexts.

Swift
func getCategory(by categoryID:PersistentIdentifier) -> Category? {
    let predicate = #Predicate<Category> {
        $0.id == categoryID
    }
    let categoryDescription = FetchDescriptor<Category>(predicate: predicate)
    return try? modelContext.fetch(categoryDescription).first
}

SwiftData is similar to Core Data in terms of multi-threaded development, but the forms are different. Read the article “Several Tips on Core Data Concurrency Programming” to learn more about the precautions for Core Data in this regard.

Convert NSManagedObject to PersistentModel

Someone may ask, can we only use SwiftDataKit to return statistical data? Is it possible to convert NSManagedObject obtained by NSFetchRequest into PersistentModel for use in SwiftData?

Similar to the previous requirement, now we want to find the ArticleCategories where any Article belongs to a specific Category.

Using the decode constructor of PersistentIdentifier, SwiftDataKit supports converting NSManagedObjectID to PersistentIdentifier. With the following code, we will obtain the PersistentIdentifier of all ArticleCategory objects that meet the criteria.

Swift
func getCollectPersistentIdentifiersByTagByKit(categoryName: String) -> [PersistentIdentifier] {
    guard let category = getCategory(by: categoryName) else {
        fatalError("Can't get tag by name:\(categoryName)")
    }
    let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "ArticleCollection")
    guard let categoryObject = category.managedObject else {
        fatalError("can't get managedObject from \(category)")
    }
    let predicate = NSPredicate(format: "SUBQUERY(articles,$article,$article.category == %@).@count > 0", categoryObject)
    fetchRequest.predicate = predicate
    fetchRequest.sortDescriptors = [.init(key: "name", ascending: true)]
    let collections = (try? modelContext.managedObjectContext?.fetch(fetchRequest)) ?? []
    // convert NSManageObjectID to PersistentIdentifier by SwiftDataKit
    return collections.compactMap(\.objectID.persistentIdentifier)
}

Then, retrieve the corresponding PersistentModel instance based on the PersistentIdentifier:

Swift
func convertIdentifierToModel<T: PersistentModel>(ids: [PersistentIdentifier], type: T.Type) -> [T] {
    ids.compactMap { self[$0, as: type] }
}

In SwiftData, two methods are provided to retrieve PersistentModel using PersistentIdentifier without using predicates. The usage and differences are explained in this tweet.

https://cdn.fatbobman.com/image-20230906200531775.png

As shown in the example above, developers can use various advanced features of Core Data in SwiftData without creating a Core Data data model and data stack.

Exchange data with Core Data Stack

If manipulating the SwiftData underlying objects still cannot meet the requirements, it is necessary to create a parallel Core Data data model and data stack, and perform data exchange between SwiftData and Core Data code.

Due to the inconsistency of NSManagedObjectID between different NSPersistentStoreCoordinators, the following functionality provided by SwiftDataKit can be used:

  • Convert PersistentIdentifier to uriRepresentation.
  • Convert uriRepresentation to PersistentIdentifier
Swift
// convert persistentIdentifier to uriRepresentation
category.id.uriRepresentation

// convert uriRepresentation to persistentIdentifier
uriRepresentation.persistentIdentifier

This way, data can be safely transferred between the SwiftData stack and the Core Data stack.

Conclusion

Through the discussion and examples in this article, we can see that although SwiftData currently cannot implement all of Core Data’s advanced features, developers can still relatively easily continue to use Core Data’s excellent features in SwiftData through the interfaces and tools provided by SwiftDataKit. This will greatly reduce the barrier for new projects to fully adopt SwiftData, without the need to synchronously maintain a set of Core Data data models and data stacks.

Of course, SwiftDataKit is only a transitional solution. As SwiftData continues to improve, it will incorporate more and more new features. We look forward to SwiftData becoming a fully functional, easy-to-use next-generation Core Data in the near future.

PS: The functionality currently provided by SwiftDataKit is still very limited. More developers are welcome to participate in the project so that everyone can enjoy the pleasure of using SwiftData for development as soon as possible.

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