Notes on SwiftData

Jan 18, 2024

Many Lift uses Swift Data for persistence. The following is my notes from where I landed during development to share with a friend.

Swift Packages


I used swift packages to seperate my models from my app code. I like and think that it provides good separation forcing you to not take shortcuts and mix things together

Migrations


You should be thinking about migrations from the get go in SwiftData. The sample code wont show this but here is how I have done it.

- Base struct named the same as your package. eg if you package is called "AppData" this struct should be called "AppDataSchema"
- In "AppDataSchema" there should be two enums like so

public struct AppDataSchema {
    enum MigrationPlan: SchemaMigrationPlan {
        static var schemas: [VersionedSchema.Type] {
            [AppDataSchema.V1.self]
        }

        static var stages: [MigrationStage] {
            []
        }
    }

    public enum V1: VersionedSchema {
        public static var versionIdentifier: Schema.Version = .init(1, 0, 0)
        public static var models: [any PersistentModel.Type] {[
            // your models: AppDataSchema.V1.[MODEL NAME].self
        ]}
    }
}

Models


- Models are placed in extensions of the VersionedSchema. 
- They are then type aliased to their intended name.

public typealias Workout = AppDataSchema.V1.Workout

extension AppDataSchema.V1 {
    @Model
    final public class Workout {
        ...
    }
}

Functions & @Transient values


Should all be stored in extensions of the type alias above "Workout". Because you need to create a new @Model for every change when you are doing migrations, this ensures the smallest amount of code is duplicated in the copy paste of your class.

extension Workout {
    @Transient
    public var success: Bool { ... }
    public func completeWorkout() { ... }
}

Initialisation 


When you are initialising new models you will often need to the around 3 things to properly insert it into your store. (Create the object, insert it into the context, set relationships). I have found the best way to do this is the builder pattern. I store the builders under the type alias to keep them name spaced

extension Workout {
    public class Builder {
        private var context: ModelContext?
		
        @discardableResult
        public func with(context: ModelContext) -> Workout.Builder {
            self.context = context
            return self
        }
        ...
    }
}

// usage
let workout = Workout.Builder()
                .with(context: modelContext)
                .begin()
                .build(from: program)

@Query's


Don't rely on SwiftData's "@Query" to house your complex queries. Break them out to their own struct within your Workout. Housing the query code with the model code seems like a reasonable trade off to me

public extension Workout {
    struct Query {
        public static let mostRecent: FetchDescriptor<Workout> = {
            var descriptor = FetchDescriptor<Workout>(...)
            ...
            return descriptor
        }()
    }
}

// Usage in SwiftUI
@Query(Workout.Query.mostRecent) var mostRecent: [Workout]

Read only


Often you will want to send you data somewhere (Live activities, server), or store it not in SwiftData. Conforming your @Models to "Codable" is probably not what you want to do. I solved this with a "ReadOnly" struct version

extension Workout {
    @Transient
    public var readOnly: Workout.ReadOnly {
        return Workout.ReadOnly(...)
    }

    public struct ReadOnly: Codable, Equatable, Hashable {
        init() { ... }
    }
}