Using CoreData in a SPM executable
Suppose you have a Swift library which uses CoreData and you’d like to use that in a command line tool for something under your control, such as running it on CI or distributing it to colleagues. You could go the route of creating a “Command Line Tool” project in Xcode, which definitely works, but distributing and signing the resulting executable can be a pain. Thanks to recent advances of the Swift Package Manager to support resources and build executables, there seems to be a simpler choice. Spoiler alert: There are hurdles.
The building blocks
Assuming we have:
- Swift package A with a library that’s using CoreData1,
- Swift package B using SPM that defines an executable and depends on the library of package A, and
- the Swift toolchain (or Xcode) installed everywhere we want to run that executable.
What we want is being able to distribute it by cloning the git repo and running swift run MyCLI
. Ideally we also want to be able to run swift build -c release
, keeping the resulting executable around and executing that later on.
As we’re developing the CLI, we run it in Xcode and everything works and we’re ready to distribute. So we think we’re done and ready to call it a day. Let’s confirm locally that it works from the command line. We run swift run MyCLI
and surely we’re good, right? … Right?
No, unfortunately not. While it works in Xcode, and building using swift run
2 works, our CLI breaks when it first initialises CoreData as it can’t find the CoreData model. Stepping into the code, we see that Bundle.module.url(forResource: "MyModel", withExtension: "momd")
returns nil
.
What’s going wrong? From the SPM documentation, it sounds like we’re in the clear:
By default, the Swift Package Manager handles common resources types for Apple platforms automatically. For example, you don’t need to declare XIB files, storyboards, Core Data file types, and asset catalogs as resources in your package manifest.
Indeed, and it does work when running the CLI from Xcode. The issue is that swift build
does not support Xcode-specific resource types after all. SPM “handles” them, but only in a way that Xcode can roll with them, but not in a way that they are usable out-of-the-box in a CLI executable.
What do we do? We need another way of passing along the CoreData model.
Option 1
A simple way is to copy the CoreData model from package A to package B, and add a new parameter to the CLI which then passes the path of that model to the CLI and then uses that to initialise the CoreData stack:
$ swift run MyCLI --model Model.xcdatamodeld
But that doesn’t work. The issue here is that we have a xcdatamodeld
file, while you initialise a NSManagedObjectModel
in code from a momd
file. Xcode handles that conversion, but we don’t have access to that magic when using swift build
.
We first need to compile that xcdatamodeld
file to a momd
and then ship that along with the CLI. This is done using the momc
executable that’s part of Xcode:
$ /Applications/Xcode.app/Contents/Developer/usr/bin/momc \
$(pwd)/Model.xcdatamodeld \
$(pwd)/Model.momd
With that ready, we can run:
$ swift run MyCLI --model Model.momd
That works, but it has the downside of cluttering up how the CLI is used and we can’t just build it as a single executable but need to worry about taking the model to wherever we want to go.
Option 2
Can we do better and somehow ship the model as part of our executable? NSManagedObjectModel
conforms to NSCoding
, so we can convert it to some data, encode that using Base64, stick the resulting string into our CLI, and then rebuild the model at runtime from that. Yes, it’s ugly, but it works!
The code to do this is straightforward:
let model = NSManagedObjectModel(contentsOf: url)!
let encoder = NSKeyedArchiver(requiringSecureCoding: true)
model.encode(with: encoder)
print(encoder.encodedData.base64EncodedString())
Take the output, add it somewhere in the code, and assign it to a string, and we can then do the inverse:
let data = Data(base64Encoded: encodedModelData)!
let decoder = try NSKeyedUnarchiver(forReadingFrom: data)
let model = NSManagedObjectModel(coder: decoder)!
let coordinator = NSPersistentContainer(name: "Model", managedObjectModel: model)
No, wait, it doesn’t actually work. Yes, it creates the model just fine, but trying to instatiate a coordinator fails. It throws a run-time exception due to “Model has a reference to (null) (0x0)”. And, indeed, if we check the managedObjectModel
property of anything in model.entities
, we see that they are all nil
. We can’t just assign to that property either, as it’s read-only. But there’s another constructor which let’s you merge models, and that, hooray, does the trick:
let data = Data(base64Encoded: encodedModelData)!
let decoder = try NSKeyedUnarchiver(forReadingFrom: data)
let decoded = NSManagedObjectModel(coder: decoder)!
let model = NSManagedObjectModel(byMerging: [decoded])!
let coordinator = NSPersistentContainer(name: "Model", managedObjectModel: model)
Now, we can finally run swift run MyCLI
.
Closing thoughts
So, we managed to build an executable using SPM that uses CoreData. We can use this in various places, such as, in my case, within a GitHub Action. I really like how Swift Package Manager is becoming a powerful addition to the Swift toolset, that let’s you take your code beyond iOS and macOS apps. Arguably CoreData isn’t a great fit for a command line tool due to the necessary workarounds and being limited to macOS, but it’s great that it can be used this way, too.
We ended up duplicating the model (alas Base64 encoded) in the code base of the CLI, which isn’t great. Ideally we add this logic the library itself, so that it can be used out-of-the-box, and have it as some kind of automatic build step so that it’s automatically updated whenever the CoreData model itself is changed.