SwiftDataKit: Unleashing Advanced Core Data Features in SwiftData

fatbobman ( 东坡肘子)
ITNEXT
Published in
9 min readSep 7, 2023

--

Photo by Jason Yuen on Unsplash

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.

A Chinese version of this post is available here.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

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:

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.

@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:

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.

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.
@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).

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

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:

@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:

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:

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.

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.

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:

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.

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
// 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.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com