What I have learned about SwiftData so far
When SwiftData was announced at WWDC '23 I made the quick decision to go all in with it on the app that I was building - I was pretty early on in the development cycle, and unlikely to be ready to release the app too soon before iOS 17 was released, so it made sense from a timing perspective. I have a pretty extensive history with CoreData so it was nice to see this new layer over the top of it.
The initial implementation was very simple and development was rapid. It really is night and day, the difference in ease of setup and getting running. However, like any new technology, there are plenty of gotchas, and this framework seems to be documented, shall we say, rather sparsely. Each new beta seems to introduce an undocumented breaking change, and working out some key features seems to be just a case of trial and error. There are a bunch of smart people writing things about it on their blogs, but they don’t tend to be able to churn out a post five minutes after downloading the new beta, which is fair enough, I reckon!
All of this is par for the course, it’s what I signed up for. But I thought I could make a little list of some things I’ve picked up as I’ve gone along. I may add more later - these things, I hope, are helpful to people using Xcode 15b6
Versioned Schemas
This feature has virtually no documentation and really only a passing mention in WWDC videos. It’s not clear from the videos what the best practice is here - perhaps the labs were helpful here. I found this post from Mohammad Azam invaluable in helping me understand a couple of simple things:
- For each version of your schema, you need a separate
VersionedSchema
. This is roughly equivalent to a new version in your.momd
in a CoreData solution. - Your versioned schema contains snapshot versions of all of the model classes that existed at that point in time. Those models are not implemented outside the
VersionedSchema
at all. - You can then use a
typealias
to refer to that in your app - somehting liketypealias MyModel = MyAppSchemaV2.MyModel
I ran into a couple of problems with my setup here after updating to Xcode 15b6.
Firstly, I was using another typealias
to keep track of the current version of the schema. I called it Schema
- stupid mistake that, because with b6 a Schema
type was introduced in the SwiftData framework, and it took me a little while to realise why my project wasn’t building! I renamed it and all was well.
Secondly, my migrations no longer worked. The error message wasn’t a lot of help - something along the lines of a you can't do this
. After messing around with the way I was setting up my containers, I found that I needed to use a specific initialiser to get it to work. I ended up with this one:
container = try! ModelContainer(
for: Schema(versionedSchema: CurrentSchema.self),
migrationPlan: MyMigrationPlan.self,
ModelConfiguration(schema: Schema(versionedSchema: CurrentSchema.self), inMemory: false, groupContainer: .identifier("my.group.identifier"), cloudKitDatabase: .automatic)
)
It seems obvious now that one would have to use the Schema(versionedSchema:
initialiser, but without rich documentation it is easy to miss.
I haven’t done a great deal in my actual migrations thus far as my changes have been relatively simple. However the simple migrations seem pretty permissive. I’m sure I will learn more about that as time goes on!
CloudKit requires optional attributes or a default value
One of the attractive things about SwiftData is how easy it is to set up to use CloudKit. I won’t go into that in detail here. But it does mean you have to make everything optional, or, the overwhelming preference in a lot of cases, provide default values.
This was all fine, until b6 introduced a bug whereby default values cannot be used. Suddenly, you have a bunch of Variable 'self._$backingData' used before being initialized
all over the place. This actually turns out to be a known issue, as the release notes say:
Properties with default values are not initialized correctly. (107887871)
Workaround: Set the property value in initializer instead.
As a workaround though, having to amend a bunch of code to use optional attributes isn’t a lot of fun, especially in a growing app, and it makes for unnecessarily ugly code. Presumably this bug will be fixed and we can revert all those changes at some point. This makes me think it might have been a good idea to use a wrapper object or even accessor functions for all the attributes.
Enums don’t seem to be able to be persisted correctly
The release notes here tell me:
Case value is not stored properly for a string rawvalue enumeration. (108634193)
Workaround: Don’t use a rawvalue with a string enum property.
However in my experience, Int
enums didn’t work either. This may have changed since I last tried it, but I have ended up just storing an Int
and then accessing that, transformed into the enum case, through either a transient var or an accessor function, depending on the situation.
Unit tests are far easier than with CoreData
There is a lot of writing available about this topic, and a lot of nice setups you could use when it comes to using preview data and so forth. My entity tests are very simple and there is none of the worrying about contexts that used to come with a complex CoreData app.
My entity test cases (in which I do not want any pre-loaded data) look something like this:
@MainActor
final class EntityTests: XCTestCase {
var context: ModelContext!
override func setUpWithError() throws {
let container = try ModelContainer(for: [Entity.self], .init(inMemory: true))
context = container.mainContext
}
... super-good and really smart test coverage ...
}
That’s it, that’s the whole story!
The @Query macro is… tricky
I am not quite on top of exactly what can and can’t be done in here. I did find, in my app, that I wasn’t able to do exactly what I needed to do in some cases.
One of my views relied on four fetches of the same entity, with different filters based on those Int
values that I’m using instead of enums. I found that I wasn’t able to use the enum inside the predicate when it was inside the @Query
macro at all, so something like this was a no-go:
@Query(
filter: #Predicate<Entity>{
$0.typeValue == EntityEnumType.myCase.rawValue
}
)
Try that, and you get the error Key path can not refer to enum case 'myCase'
.
While that didn’t compile, I found that sticking the content of it in a static function somewhere did work. So I ended up with:
public static func EntityTypePredicate(type: EntityEnumType) -> Predicate<Entity> {
return #Predicate<Entity>{
$0.typeValue == type.rawValue
}
}
@Query(
filter: EntityTypePredicate(type: .myCase)
)
That’s all, for now. I’ll continue to flesh this out as I discover more and new betas bring new and exciting changes.