@Model for CoreData

At WWDC 2023 Apple finally released a persistence framework specifically for Swift: SwiftData. My ManagedModels provides a similar API, on top of regular CoreData, and doesn’t require iOS 17+.

Article Sections:

TL;DR

ManagedModels is a package that provides a Swift 5.9 macro similar to the SwiftData @Model. It can generate CoreData ManagedObjectModel’s declaratively from Swift classes, w/o having to use the Xcode “CoreData Modeler”.
Unlike SwiftData it doesn’t require iOS 17+ and works directly w/ CoreData. It is not a direct API replacement, but a look-a-like.

Example model class:

@Model
class ToDo: NSManagedObject {
    var title: String
    var isDone: Bool
    var attachments: [ Attachment ]
}

setting up a store in SwiftUI:

ContentView()
    .modelContainer(for: ToDo.self)

Performing a query:

struct ToDoListView: View {
    @FetchRequest(sort: \.isDone)
    private var toDos: FetchedResults<ToDo>

    var body: some View {
        ForEach(toDos) { todo in
            Text("\(todo.title)")
                .foregroundColor(todo.isDone ? .green : .red)
        }
    }
}

TL;DR ✔︎

A Little Bit of History

Prior iOS 17 / macOS 14 the recommended Apple way to do persistence is CoreData, an old framework that originates way back to NeXT times. Back then NeXT had a product called the “Enterprise Objects Framework” / EOF, probably one of the first ORM’s. Unlike CoreData, EOF was able to talk to a large range of database systems, from RDBMS like Oracle, to more obscure things like LDAP servers. Good times.

EOModeler Screenshot

CoreData picked up on that, but refocused on client-side only, local storage. Either in XML files or in SQLite databases. It is worth mentioning that it lost the “mapping” aspect of ORMs/EOF. I.e. it is not possible to take an arbitrary SQLite database and map that to the model layer objects. CoreData “owns” the schema of the underlying database.

There is a confusing terminology mismatch between CoreData and SwiftData. When people talk about a “model” today, they usually mean what is called the “entity” in an ER model. Previously a “model” was the set of entites and their relationships.
CoreData is using the “old” naming (e.g. ManagedObjectModel) while SwiftData uses todays convention. The “model” is now called the Schema and the “models” are the classes.

A problem w/ CoreData (or Swift, depending on your PoV) is that it makes extensive use of the Objective-C runtime features, i.e. heavy swizzling, dynamic subclassing and more. That bites w/ the static nature that Swift developers prefer.

Another problem w/ CoreData is the “modeler” that is (now) part of Xcode. In that the schema is setup dynamically, it gets stored to disk and loaded at runtime (and then uses reflection to match up the schema with the classes). It is a little similar to using Interface Builder vs. SwiftUI.

In short: CoreData and Swift was never a particularily good match.

SwiftData

So at WWDC 2023 Apple eventually released SwiftData. It’s available for iOS 17+ and macOS 14+, same for the new Observation framework it integrates with.

SwiftData turned out to be quite surprising. I think it is fair to say that many envisioned something completely reimagined, built from scratch, specifically for Swift. Like Apple did with SwiftUI. Structures and async/await everywhere.
SwiftData is that not. At least not yet.

SwiftData is a Swift shim around CoreData, but it completely hides it (which may be a hint that a different variant will be provided at one point). SwiftData PersistentModel’s become CoreData NSManagedObject’s under the hood (check BackingData).
And PersistentModel’s still have to be actual class objects (reference types) vs. structures aka value types that Swift devs love.

So what does the API look like. The two key components are the @Model macro and the @Query property wrapper for SwiftUI. That’s all you need to get up and running:

import SwiftUI
import SwiftData

@Model class Contact {
    var name = ""
    var addresses = [ Address ]()
}

@Model class Address {
    var street: String = ""
    var contact: Contact?
}

struct ContentView: View {
    @Query var contacts: [ Contact ]

    var body: some View {
        ForEach(contacts) { contact in
            Text(verbatim: contact.name)
        }
        .modelContainer(for: Contact.self)
    }
}

Much more convenient than fiddling w/ CoreData directly. Models directly declared in code, CoreData setup procedure massively simplified.

Here is a set of links to SwiftData WWDC videos, they are all pretty good:

ManagedModels

Now ManagedModels tries to provide that convenience for CoreData itself. Instead of wrapping CoreData, it directly builds upon CoreData. The primary advantage over SwiftData is that it back deployes a long way. And if a CoreData application exists already, it can be easily added to that, dropping the requirement for the xcdatamodel.

The SwiftData example from above, but using ManagedModels:

import SwiftUI
import ManagedModels

@Model class Contact: NSManagedObject {
    var name = ""
    var addresses = [ Address ]()
}

@Model class Address: NSManagedObject {
    var street = ""
    var contact: Contact?
}

struct ContentView: View {
    @FetchRequest var contacts: FetchedResults<Contact>

    var body: some View {
        ForEach(contacts) { contact in
            Text(verbatim: contact.name)
        }
        .modelContainer(for: Contact.self)
    }
}

That its slightly different, but almost as convenient.

Just add https://github.com/Data-swift/ManagedModels.git as a package dependency to get this up and running.

Note: When building for the first time, Xcode will ask you to approve the use of the provided macro.

Differences to SwiftData

It looks similar, and kinda is similar, but there are some differences.

Explicit Superclass

First of all, the classes must explicitly inherit from NSManagedObject. That is due to a limitation of Swift Macros. A macro can add protocol conformances, but it can’t a superclass to a type.

Instead of just this in SwiftData:

@Model class Contact {}

the superclass has to be specified w/ ManagedModels:

@Model class Contact: NSManagedObject {}
@FetchRequest instead of @Query

Instead of using the new SwiftUI @Query wrapper, the already available @FetchRequest property wrapper is used.

SwiftData:

@Query var contacts : [ Contact ]

ManagedModels:

@FetchRequest var contacts: FetchedResults<Contact>
Properties

The properties now work quite similar, thanks to some hints by Aleksandar Vacić.

ManagedModels also provides implementations of the @Attribute, @Relationship and @Transient macros.

More complex Swift types are always stored as JSON by ManagedModels. RawRepresentable’s w/ a base types (like enum Color: String {...} or enum Priority: Int {...}) are stored as the base type.

Codable attributes should now work, untested. It works a little different to SwiftData, which decomposes some Codables (splits nested properties into own attributes / database columns).

Initializers

A CoreData object has to be initialized through some very specific initializer, while a SwiftData model class must have an explicit init, but is otherwise pretty regular.

The ManagedModels @Model macro generates a set of helper inits to deal with that. But the general recommendation is to use a convenience init like so:

convenience init(title: String, age: Int = 50) {
    self.init()
    title = title
    age = age
}

If the own init prefilles all properties (i.e. can be called w/o arguments), the default init helper is not generated anymore, another one has to be used:

convenience init(title: String = "", age: Int = 50) {
    self.init(context: nil)
    title = title
    age = age
}

The same init(context:) can be used to insert into a specific context. Often necessary when setting up relationships (to make sure that they live in the same NSManagedObjectContext).


Those should be the most important differences ✔︎ Still a few, but workable.

Example App

There is a small SwiftUI todo list example app, demonstrating the use of ManagedModels. It has two connected entities and shows the general setup: Managed ToDos.

Everyone loves screenshots, this is what it looks like:

It should be self-explanatory. Works on macOS 13 and iOS 16, due to the use of the new SwiftUI navigation views. Could be backported to even earlier versions.

Northwind

There is also a significantly more complex example: Northwind for ManagedModels.

This is the old Northwind database packaged up as a Swift package that works with ManagedModels. It contains a set of model classes and a prefilled database which makes it ideal for testing, to get started quickly.

It is actually a straight port of a SwiftData version: NorthwindSwiftData and as a result a good way to compare how SwiftData and ManagedModels differ.

Sample usage (import https://github.com/Northwind-swift/NorthwindSwiftData.git):

import SwiftUI
import NorthwindSwiftData // @Northwind-swift/NorthwindManagedModels

@main
struct NorthwindApp: App {

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(try! NorthwindStore.modelContainer())
    }
}

struct ContentView: View {

    @FetchRequest(sort: \.name)
    private var products: FetchedResults<Product>
    
    var body: some View {
        List {
            ForEach(products) { product in
                Text(verbatim: product.name)
            }
        }
    }    
}

Internals

The provided @Model is a non-trivial Swift Macro and might be helpful for people interested in how to write such.

Interested how this Swift code:

@Model class Contact: NSManagedObject {    
    var name: String
    var age: Int
}

is expanded by @Model? Buckle up:

class Contact: NSManagedObject {

    // @_PersistedProperty
    var name: String
    {
        set {
            setValue(forKey: "name", to: newValue)
        }
        get {
            getValue(forKey: "name")
        }
    }    

    // @_PersistedProperty
    var age: Int
    {
        set {
            setValue(forKey: "age", to: newValue)
        }
        get {
            getValue(forKey: "age")
        }
    }    
    
    /// Initialize a `Contact` object, optionally providing an
    /// `NSManagedObjectContext` it should be inserted into.
    /// - Parameters:
    //    - entity:  An `NSEntityDescription` describing the object.
    //    - context: An `NSManagedObjectContext` the object should be inserted into.
    override init(entity: CoreData.NSEntityDescription, insertInto context: NSManagedObjectContext?)
    {
        super.init(entity: entity, insertInto: context)
    }

    /// Initialize a `Contact` object, optionally providing an
    /// `NSManagedObjectContext` it should be inserted into.
    /// - Parameters:
    //    - context: An `NSManagedObjectContext` the object should be inserted into.
    init(context: CoreData.NSManagedObjectContext?) {
        super.init(entity: Self.entity(), insertInto: context)
    }

    /// Initialize a `Contact` object w/o inserting it into a
    /// context.
    init() {
        super.init(entity: Self.entity(), insertInto: nil)
    }

    static let schemaMetadata : [ CoreData.NSManagedObjectModel.PropertyMetadata ] = [
        .init(name: "name", keypath: \Contact.name,
              defaultValue: nil,
              metadata: CoreData.NSAttributeDescription(name: "name", valueType: String.self)),
        .init(name: "age", keypath: \Contact.age,
              defaultValue: nil,
              metadata: CoreData.NSAttributeDescription(name: "age", valueType: Int.self))]

    static let _$originalName : String? = nil
    static let _$hashModifier : String? = nil
}
extension Contact: ManagedModels.PersistentModel {}

Essentially:

  • Attaches setters and getters to properties, which hit the actual CoreData storage.
  • Override of init(entity:insertInto:), that ties into the entity.
  • A helper init(context:) that calls init(entity:insertInto:) with the entity.
  • Two supporting attributes for CoreData migration: _$originalName and _$hashModifier.

If someone has more specific questions, feel free to ping me.

Closing Notes

ManagedModels still has some open ends and I’d welcome any PR’s enhancing the package.

Even though I’ve re-implemented EOF quite a few times already (e.g. in SOPE, GETobjects, ZeeQL), I have to admit that I’m not a CoreData expert specifically 😀 So I’m happy about any feedback on things I might be doing incorrectly. Though it does seem to work quite well.

Either way, I hope you like it!

Contact

Feedback is warmly welcome: @helje5, @helge@mastodon.social, me@helgehess.eu. GitHub.

Want to support my work? Buy an app: Code for SQLite3, Past for iChat, SVG Shaper, HMScriptEditor. You don’t have to use it! 😀

Written on September 28, 2023