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() { ... }
}
}