Unveiling the Data Modeling Principles of SwiftData

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the improvements of SwiftData, the ability to declare data models purely in code undoubtedly left a deep impression on Core Data developers. This article will delve into how SwiftData creates data models through code, the new language features it utilizes, and demonstrate how to create PersistentModel instances by declaring code.

Three Facts

Understanding the following three facts is crucial for gaining a better grasp and comprehension of the modeling principles of SwiftData, as well as why SwiftData adopts the methods described in this article.

SwiftData is a framework built on top of Core Data.

Although Apple rarely emphasizes the relationship between SwiftData and Core Data, it is undeniable that the SwiftData framework is built on top of Core Data. There are several benefits that Core Data brings to SwiftData:

  • The database file format is compatible, existing data can be directly operated with the new framework.
  • Inherits the stability verification of Core Data, significantly reducing potential issues.

Although SwiftData is based on Core Data, it does not mean that the same programming principles as Core Data need to be used when developing with SwiftData. Since SwiftData combines many of the latest features of the Swift language, developers often need to use a fresh mindset to redesign data processing logic in many situations.

In SwiftDataKit: Unleashing Advanced Core Data Features in SwiftData, I explained how to leverage the techniques to access the underlying Core Data objects corresponding to SwiftData elements.

SwiftData is closely associated with the Swift language and is a forerunner of the Swift language.

In recent years, Apple has introduced several frameworks with the prefix “Swift”, such as SwiftUI, Swift Charts, SwiftData, and so on. This naming convention reflects the close integration of these frameworks with the Swift language. In order to implement these frameworks, Apple has actively promoted the development of the Swift language, proposing new proposals and applying partially determined features in the frameworks. These frameworks extensively adopt new features of Swift, such as result builders, property wrapper, macros, and initialization accessors, making them pioneers and testing grounds for new language features.

Unfortunately, it is currently not possible for these frameworks to be cross-platform and open source. This is mainly because they rely on proprietary APIs within the Apple ecosystem. This hinders the opportunity to promote the Swift language using these excellent frameworks on other platforms.

Overall, frameworks like SwiftData are closely related to the Swift language and play a leading role in adopting new features. Learning these frameworks is also a way to master the new features of the Swift language.

Pure code declaration of data models is a step forward compared to Core Data, but it is not a revolution.

Although SwiftData brings surprises to Core Data developers by using a pure code-based approach to declare data models, this has already been applied in other frameworks and languages. Compared to Core Data, it has made some progress, but cannot be considered a complete revolution.

However, SwiftData has its unique innovations in implementing this concept. This is mainly due to its close integration with the Swift language. By creating and using newly emerged language features, SwiftData achieves declarative modeling in a more concise, efficient, and modern programming paradigm.

Analysis of Model Code

In this section, we will analyze the model code of SwiftData, which is based on the models provided in the SwiftData project template in Xcode. Let’s uncover its mysterious veil.

Swift
@Model
final class Item {
    var timestamp: Date = Date.now // Default value added

    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

The Role of Macro

If we ignore the @Model macro flag, the code above is exactly the same as defining a standard Swift class. However, with SwiftData and the @Model macro, we can extend it to become a data model with a complete description based on the simple representation we provide.

In Xcode, when expanding macros, we will be able to see the complete code after macro expansion (@_PersistedProperty can be expanded twice).

https://cdn.fatbobman.com/swiftData-model-macro-expand-demo_2023-10-01_15.53.39.2023-10-01%2015_54_37.gif

The complete code after expansion is as follows:

Swift
public final class Item {
    // User-defined persistence properties
    public var timestamp: Date = Date.now {
        // Init Accessor, in the process of constructing instances, adds construction capabilities to calculated properties
        @storageRestrictions(accesses: _$backingData, initializes: _timestamp)
        init(initialValue) {
            _$backingData.setValue(forKey: \.timestamp, to: initialValue)
            _timestamp = _SwiftDataNoType()
        }
        get {
            _$observationRegistrar.access(self, keyPath: \.timestamp)
            return self.getValue(forKey: \.timestamp)
        }
        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \.timestamp) {
                self.setValue(forKey: \.timestamp, to: newValue)
            }
        }
    }

    // The underlined version corresponding to timestamp has no practical use yet.
    @Transient
    private var _timestamp: _SwiftDataNoType = .init()

    // User-defined constructor
    public init(timestamp: Date) {
        self.timestamp = timestamp
    }

    // A type used to wrap the corresponding managed object (NSManagedObject) instance without persistence (@Transient)
    @Transient
    private var _$backingData: any SwiftData.BackingData<Item> = Item.createBackingData()

    public var persistentBackingData: any BackingData<Item> {
        get {
            self._$backingData
        }
        set {
            self._$backingData = newValue
        }
    }

    // Provide model metadata for creating Scheme
    public static var schemaMetadata: [Schema.PropertyMetadata] {
        return [
            SwiftData.Schema.PropertyMetadata(name: "timestamp", keypath: \Item.timestamp, defaultValue: Date.now, metadata: nil),
        ]
    }

    // Construct PersistentModel from backingData
    public init(backingData: any BackingData<Item>) {
        _timestamp = _SwiftDataNoType()
        self.persistentBackingData = backingData
    }

    // Observation register required by the Observation protocol
    @Transient
    private let _$observationRegistrar: ObservationRegistrar = Observation.ObservationRegistrar()

    // Empty type, used for the underscore version of the property
    struct _SwiftDataNoType {}
}
// PersistentModel Protocol
extension Item: SwiftData.PersistentModel {}
// Observable Protocol
extension Item: Observation.Observable {}

The following will describe in detail the specifics of the generated code.

Metadata of Model

In Core Data, developers can generate XML formatted .xcdatamodeld files using the data model editor provided by Xcode. This file stores the descriptive information used to create a data model (NSManagedObjectModel).

Read the article Exploring CoreData — From Data Model Creation to Managed Object Instances to learn more information.

SwiftData integrates the above description information directly into the declaration code through the Model macro.

Swift
public static var schemaMetadata: [Schema.PropertyMetadata] {
    return [
        SwiftData.Schema.PropertyMetadata(name: "timestamp", keypath: \Item.timestamp, defaultValue: Date.now, metadata: nil),
    ]
}

Each class that conforms to the PersistentModel protocol must provide a class property named schemaMetadata. This property provides detailed metadata for creating a data model, which is generated by parsing the persistent property definitions of the current type.

The name corresponds to the Attribute Name of the data model, keypath is the KeyPath of the corresponding property for the current type, defaultValue corresponds to the default value set for the property in the declaration (if there is no default value, it is nil), and metadata contains other information such as relationship descriptions, delete rules, original names, and so on.

Swift
@Attribute(.unique, originalName: "old_timestamp")
var timestamp: Date = Date.now

static var schemaMetadata: [SwiftData.Schema.PropertyMetadata] {
  return [
    SwiftData.Schema.PropertyMetadata(name: "timestamp", keypath: \Item.timestamp, defaultValue: Date.now, metadata: SwiftData.Schema.Attribute(.unique, originalName: "old_timestamp"))
  ]
}

defaultValue is equivalent to the default value functionality that developers create for attributes in the Xcode model editor. Since SwiftData allows properties in data models to be declared as more complex types (such as enums, structs that conform to the Encoded protocol, etc.), SwiftData maps the corresponding storage type using the given KeyPath when constructing the model. Additionally, each PropertyMetadata does not necessarily correspond to a single field in SQLite (it may create multiple fields based on the type).

SwiftData will directly read the class property schemaMetadata to complete the creation of Schema and even ModelContainer.

Swift
let schema = Schema([Item.self])

Developers can use the new API NSManagedObjectModel.makeManagedObjectModel of Core Data to generate the corresponding NSManagedObjectModel by declaring model code for SwiftData.

Swift
let model = NSManagedObjectModel.makeManagedObjectModel(for: [Item.self])

BackingData

Each instance of PersistentModel corresponds to a managed object instance (NSManagedObject) at the underlying level, which is wrapped in a type called _DefaultBackingData (compliant with the BackingData protocol).

Swift
@Transient
private var _$backingData: any SwiftData.BackingData<Item> = Item.createBackingData()

public var persistentBackingData: any BackingData<Item> {
    get {
        self._$backingData
    }
    set {
        self._$backingData = newValue
    }
}

createBackingData is a class method provided by the PersistentModel protocol. It creates an instance that conforms to the BackingData protocol by retrieving information from the already loaded data model, such as _DefaultBackingData<Item>.

When calling createBackingData, SwiftData cannot solely rely on the schemaMetadata provided by the current class to create an instance. In other words, createBackingData can only correctly construct a PersistentModel instance after a ModelContainer instance has been created. This is different from Core Data, where instances can be created solely based on NSEntityDescription information without loading NSManagedObjectModel.

Here is the code used in SwiftDataKit to fetch the corresponding NSManagedObject instance from BackingData:

Swift
public extension BackingData {
    // Computed property to access the NSManagedObject
    var managedObject: NSManagedObject? {
        guard let object = getMirrorChildValue(of: self, childName: "_managedObject") as? NSManagedObject else {
            return nil
        }
        return object
    }
}

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
}

Through the following code, you can see:

Swift
private var _$backingData: any SwiftData.BackingData<Item> = Item.createBackingData()

When creating an instance of backingData using createBackingData in SwiftData, there is no need for the presence of ModelContext (NSManagedObjectContext). It internally uses the following methods to build managed objects:

Swift
let item = Item(entity: Item.entity(), insertInto: nil)

This also explains why in SwiftData, after creating an instance of PersistentModel, we must explicitly register (insert) it onto a ModelContext.

Swift
let item = Item(timestamp:Date.now)
modelContext.insert(item) // must insert into some modelContext

Since backingData (_DefaultBackingData) does not have a public constructor, we cannot construct that data through a managed object instance. Another constructor in PersistentModel is provided for SwiftData internally to convert a managed object into a PersistentModel.

Swift
public init(backingData: any BackingData<Item>) {
    _timestamp = _SwiftDataNoType()
    self.persistentBackingData = backingData
}

Init Accessors

By examining the complete expanded code, the timestamp is transformed into a computed property with a constructor by macro code.

Swift
public var timestamp: Date = Date.now {
    @storageRestrictions(accesses: _$backingData, initializes: _timestamp)
    init(initialValue) {
        _$backingData.setValue(forKey: \.timestamp, to: initialValue)
        _timestamp = _SwiftDataNoType()
    }
    get {
        _$observationRegistrar.access(self, keyPath: \.timestamp)
        return self.getValue(forKey: \.timestamp)
    }
    set {
        _$observationRegistrar.withMutation(of: self, keyPath: \.timestamp) {
            self.setValue(forKey: \.timestamp, to: newValue)
        }
    }
}

So, how does SwiftData build the current value for its PersistentModel instance when constructing it? Let’s take a look at the code below:

Swift
public init(timestamp: Date) {
    self.timestamp = timestamp
}

let item = Item(timestamp: Date.distantPast)

When using createBackingData in SwiftData to create an instance of Item, first, a NSManagedObject instance with a default value of Date.now for timestamp will be created (passed to Schema through schemaMetadata and wrapped in backingData). Then, a new value (from the constructor method parameter, Date.distantPast) will be set for timestamp through the initialization accessors.

Init Accessors is a new feature introduced in Swift 5.9. It incorporates computed properties into the definite initialization analysis. This allows direct assignment to computed properties in initialization methods, which will be transformed into the corresponding initialization values for stored properties.

The meaning of this code snippet is:

Swift
@storageRestrictions(accesses: _$backingData, initializes: _timestamp)
init(initialValue) {
    _$backingData.setValue(forKey: \.timestamp, to: initialValue)
    _timestamp = _SwiftDataNoType()
}
  • accesses: _$backingData indicates that _$backingData storage property will be accessed in init. This means that _$backingData must be initialized before calling this init accessor to initialize timestamp.
  • initializes: _timestamp indicates that this init accessor will initialize the _timestamp storage property.
  • initialValue: corresponds to the initial value passed in the constructor argument, which in this example is Date.distantPast.

Init Accessors, as a new feature in the Swift language, provides a more unified, precise, clear, and flexible initialization model compared to Property Wrappers. SwiftData leverages this functionality to explicitly assign values to persistent properties during the construction phase, reducing the workload for developers and making the declaration of model code more in line with the logic of the Swift language.

Integrating with the Observation framework

Unlike NSManagedObject’s binding with SwiftUI views using the Combine framework, SwiftData’s PersistentModel adopts a new Observation framework.

Please read A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance to learn more about the Observation framework.

To meet the requirements of the Observation framework, SwiftData has added the following content to the model code:

Swift
extension Item: Observation.Observable {}

public final class Item {
    // User-defined persistence properties
    public var timestamp: Date = .now {
        ....
        get {
            _$observationRegistrar.access(self, keyPath: \.timestamp)
            return self.getValue(forKey: \.timestamp)
        }
        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \.timestamp) {
                self.setValue(forKey: \.timestamp, to: newValue)
            }
        }
    }

    ....

    // Observation register required by the Observation protocol
    @Transient
    private let _$observationRegistrar: ObservationRegistrar = Observation.ObservationRegistrar()
}

By using _$observationRegistrar in the get and set methods of persistent properties, the observation mechanism at the granularity of properties is implemented for registering and notifying observers. This approach can significantly reduce unnecessary view updates caused by unrelated property changes.

From the above registration method, it can be inferred that developers must explicitly call the set method of persistent properties in order for observers to receive notifications of data changes (by calling the onChange closure of withObservationTracking).

Get and Set Methods

The PersistentModel protocol defines some get and set methods and provides default implementations. For example:

Swift
public func getValue<Value, OtherModel>(forKey: KeyPath<Self, Value>) -> Value where Value : Decodable, Value : RelationshipCollection, OtherModel == Value.PersistentElement

public func getTransformableValue<Value>(forKey: KeyPath<Self, Value>) -> Value

public func setValue<Value>(forKey: KeyPath<Self, Value>, to newValue: Value) where Value : Encodable

public func setValue<Value>(forKey: KeyPath<Self, Value>, to newValue: Value) where Value : PersistentModel

Using these methods, developers can read or write a specific persistent property. Please note that using the set methods mentioned above (e.g. setValue) to set a new value for a property will bypass the Observation framework, and property subscribers will not be notified of the property’s changes (views will not automatically update). Similarly, if the persistent properties of an NSManagedObject instance corresponding to PersistentModel are directly modified using SwiftDataKit, no notifications will be generated.

Swift
item.setValue(forKey: \.timestamp, to: date) // Do not notify timestamp subscribers
item.timestamp = date // Notify subscribers of timestamp

The BackingData protocol also provides the definition and default implementation of the get and set methods. The setValue method provided by BackingData can only modify the underlying NSManagedObject properties corresponding to the PersistentModel, similar to modifying managed object instances through SwiftDataKit. Using this method directly will result in inconsistency between the data of the underlying NSManagedObject and the data of the surface-level PersistentModel.

In addition to providing functionality similar to the get and set methods of NSManagedObject, the PersistentModel protocol also performs other operations with its get and set methods. These operations include mapping a property of PersistentModel to multiple properties of NSManagedObject (when the property is a complex type), as well as thread scheduling (to ensure thread safety) and other tasks.

Other

In addition to the above content, the PersistentModel protocol also declares several other properties:

  • hasChanges: indicates whether changes have occurred, similar to the same-named property in NSManagedObject.
  • isDeleted: indicates whether it has been added to the deletion list of ModelContext, similar to the same-named property in NSManagedObject.
  • modelContext: the ModelContext to which the current PersistentModel is registered. Its value is nil before registration through insert.

Compared to NSManagedObject, SwiftData currently exposes limited APIs. As SwiftData continues to evolve, more functionalities may be provided for developers to use.

Summary

This article analyzes in detail a piece of code from the SwiftData simple model, providing an in-depth explanation of its implementation principles, including model construction, PersistentModel instance generation, and property observation notification mechanism, among others. The analysis process is also an important way to proficiently use a framework.

During the code analysis process, we not only deepen our understanding of the SwiftData framework but also gain a more intuitive understanding of many new features of the Swift language, making it a win-win situation.

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