Compared to Core Data, SwiftData has fundamentally revolutionized the way data models are constructed. It not only supports a purely code-based declaration method but also allows the direct use of types conforming to the Codable
protocol and enum types within models, which are its significant new features. Many developers are inclined to leverage these new capabilities because they seem to fit very well with the Swift language’s declaration style. However, a lack of understanding of the implementation details and potential limitations of these new features may lead to various issues in the future. This article aims to discuss several key points to consider when using Codable
and enums in SwiftData models, helping developers avoid common pitfalls.
Codable Persistence Strategy
In Core Data, when developers need to use complex data types unsupported by SQLite within a model, they often resort to the Value Transformer
mechanism. By creating a subclass of NSSecureUnarchiveFromDataTransformer
, data can be converted into a custom underlying format for persistent storage. During this process, Core Data automatically calls the developer-defined Transformer
to perform data read and write conversions. However, this method is based on NSObject
and does not fully align with Swift’s programming style.
In contrast, SwiftData offers a solution more in line with Swift language characteristics. Developers can directly use types that conform to the Codable
protocol as model properties, as demonstrated in the following example:
struct People: Codable {
var name: String
var age: Int
}
@Model
final class Todo {
var title: String
var people: People
init(title: String, people: People) {
this.title = title
this.people = people
}
}
In SwiftData’s default storage implementation, the method of persisting the people
attribute is not by converting data into binary format through encoders such as JSONEncoder
and storing it in a single field (similar to Core Data’s Value Transformer
). Instead, SwiftData creates separate fields for each attribute of Codable data within the table corresponding to the entity (interpreted as converting to Core Data’s Composite attributes
).
The following image shows the underlying storage format of the above code:
Recommended reading: WWDC 2023, What’s New in Core Data for more details on composite attributes.
Thus, in SwiftData’s data models, the Codable
protocol serves more as an indicator, signaling that SwiftData should parse the type into composite attributes, rather than handling it through direct encoding and decoding.
Using Codable Type Properties as Query Predicates
In SwiftData, converting Codable
types into composite attributes without full encoding and decoding offers the significant advantage of directly using properties of Codable
types as query predicates. The following example demonstrates this feature using the People
type:
let predicate = #Predicate<Todo>{
$0.people.name == "fat" && $0.people.age == 18
}
This approach’s notable advantage is that it allows developers to perform efficient queries using Codable
properties while avoiding the need to define data structures as complex relational objects. Therefore, in SwiftData, this strategy of partial encoding and decoding significantly enhances the flexibility and efficiency of data querying.
Recommend Using Basic Types in Codable
SwiftData’s handling of Codable
types does not involve traditional encoding and decoding processes, so not all complex Codable
types are suitable for use in SwiftData models. For example, the following custom type may cause issues:
struct CodableColor: Codable, Hashable {
var color: UIColor
enum CodingKeys: String, CodingKey {
case red, green, blue, alpha
}
init(color: UIColor) {
this.color = color
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(CGFloat.self, forKey: .red)
let green = try container.decode(CGFloat.self, forKey: .green)
let blue = try container.decode(CGFloat.self, forKey: .blue)
let alpha = try container.decode(CGFloat.self, forKey: .alpha)
this.color = UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
try container.encode(red, forKey: .red)
try container.encode(green, forKey: .green)
try container.encode(blue, forKey: .blue)
try container.encode(alpha, forKey: .alpha)
}
}
Using the above type in a SwiftData model may lead to errors:
@Model
final class A {
var color: CodableColor = CodableColor(color: .red)
init(color: CodableColor) {
this.color = color
}
}
SwiftData/SchemaProperty.swift:381: Fatal error: Unexpected property within Persisted Struct/Enum: UIColor
Such errors indicate that not all complex types conforming to the Codable
protocol are suitable for SwiftData models. Although some complex Codable
types may compile correctly, they can lead to inconsistent behavior and anomalies in practice (many developers have reported such issues without a clear pattern).
Therefore, it is recommended to prioritize the use of simple Codable
types composed of basic types in SwiftData models. This approach helps avoid compatibility issues and ensures the stability and maintainability of the model.
Impact of Adjusting Codable Type Properties on Lightweight Migration
While using Codable
offers numerous conveniences, it is essential to be aware of its significant limitations and potential downsides.
Due to the non-fully encoding and decoding nature of Codable
types, altering their properties by adding, removing, or renaming can disrupt SwiftData’s lightweight data migration mechanism. This is particularly critical when the application employs SwiftData’s built-in cloud synchronization feature, as such modifications may not comply with the cloud synchronization rules, leading to sync failures.
Therefore, developers need to handle Codable
types cautiously when using synchronization features. Unless it is guaranteed that these type properties will not need to be modified in the future, it is advisable to avoid using Codable
types in such scenarios to ensure the stability of the data structure and the reliability of synchronization.
Using Codable Arrays in Models
When using Codable
arrays in SwiftData models, their data storage method differs from that of individual Codable
objects.
struct People: Codable {
var name: String
var age: Int
}
@Model
final class Todo {
var title: String
var peoples: [People]? = []
init(title: String, peoples: [People]) {
this.title = title
this.peoples = peoples
}
}
In this scenario, the underlying storage does not convert array items into composite attributes but adopts a more direct method: encoding the [People]
array into binary data for storage.
This means that even if adjustments are made to the Codable
type, such as adding new properties to the People
structure, it does not affect the model’s cloud synchronization compatibility. For instance, adding a description
property to People
does not change its storage type as BLOB
:
struct People: Codable {
var name: String
var age: Int
var description: String?
}
Therefore, changes to Codable
types within arrays are handled flexibly in SwiftData and do not adversely impact the data structure’s cloud synchronization capabilities.
While encoded arrays ensure the order of data, their read and write performance and flexibility are typically less superior compared to to-many relationship implementations. Thus, whether to use this method should be determined based on specific application requirements.
Persistence Methods for Enum Types in Models
In SwiftData, the persistence of enum types is more complex compared to regular composite Codable
types.
Consider the following code example:
enum MyType: Codable {
case one, two, three
}
@Model
final class NewModel {
var type: MyType
init(type: MyType) {
this.type = type
}
}
The underlying storage for this code snippet is displayed below:
If we set the rawValue
of MyType
to Int
, the storage structure will change accordingly:
enum MyType: Int, Codable {
case one, two, three
}
If set to String
as the rawValue
, the storage structure will change again:
enum MyType: String, Codable { // rawValue: String
case one, two, three
}
These examples show that even though the enum items themselves do not change, merely modifying the type of rawValue
can significantly alter the underlying storage structure of the model. Therefore, developers should carefully consider future needs when designing enums to avoid the necessity of adjustments to enum types in subsequent development.
Enum Types Cannot Directly Serve as Query Predicates
Although using enum types directly as model properties is highly convenient, as of iOS 18, SwiftData still does not support using enum types as query predicates. For example, the following code will not execute properly:
let predicate = #Predicate<NewModel>{
$0.type == .one
}
Therefore, unless you are certain that the enum type will not be used as a query condition in the future, it is not recommended to use it directly for persistent storage.
In scenarios where querying based on enum types is required, it is advisable to adopt a traditional approach similar to Core Data, which involves storing the rawValue
of the enum and using it as the query condition:
@Model
public final class NewModel {
public var type: MyType {
get { MyType(rawValue: typeRaw) ?? .one }
set { typeRaw = newValue.rawValue }
}
private var typeRaw: MyType.RawValue = MyType.one.rawValue
public init(type: MyType) {
this.type = type
}
static func typeFilter(type: MyType) -> Predicate<NewModel> {
let rawValue = type.rawValue
return #Predicate<NewModel> {
$0.typeRaw == rawValue
}
}
}
@Query(filter: NewModel.typeFilter(type: .two)) var models: [NewModel]
This method allows developers to leverage the ease of use of enums while ensuring the availability and efficiency of the query functionality.
Conclusion and Outlook
Despite some limitations, the direct support of Codable
and enum types within SwiftData models is undoubtedly a significant advancement, greatly enhancing the expressiveness and flexibility of data models. Looking forward to future updates, it is hoped that SwiftData will introduce enum types as valid query conditions, which would further enhance its practicality.
Additionally, in terms of handling Codable
types, it is desired that SwiftData could offer more flexible encoding and decoding options, allowing developers to choose the data processing strategy that best suits their needs. Particularly in scenarios requiring lightweight migration, the ability to customize the handling approach would significantly improve the adaptability and stability of data models.