axiom-swiftdata-migration by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftdata-migrationSwiftData 模式迁移在模型变更时安全地移动您的数据。核心原则 SwiftData 的 willMigrate 只能看到旧模型,didMigrate 只能看到新模型——您永远无法同时访问两者。这一限制塑造了所有迁移策略。
要求 iOS 17+, Swift 5.9+ 目标 iOS 26+ (包含 propertiesToFetch 等功能)
SwiftData 可以自动迁移以下情况:
@Attribute(originalName:))以下情况需要自定义迁移:
String → AttributedString, → )广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
IntString这些是开发者提出的真实问题,本技能旨在解答:
→ 本技能展示了两阶段迁移模式,以规避 willMigrate/didMigrate 的限制
→ 本技能解释了关系预取以及跨模式版本维护反向关系
→ 本技能涵盖了显式反向关系要求和 iOS 17.0 字母顺序命名错误
→ 本技能展示了用于轻量级迁移的 @Attribute(originalName:) 模式
→ 本技能强调真实设备测试,并解释为什么模拟器成功不能保证生产安全
→ 本技能解释了 SwiftData 的设计:每个 VersionedSchema 都是一个完整的快照,而不是差异
→ 本技能提供了针对模式版本不匹配的调试步骤
→ 本技能涵盖了迁移测试工作流程、真实设备测试要求和验证策略
关键 这是塑造所有 SwiftData 迁移模式的架构约束。
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ 可以访问:SchemaV1 模型(旧)
let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// ❌ 无法访问:SchemaV2 模型
// SchemaV2.Note 尚不存在
},
didMigrate: { context in
// ✅ 可以访问:SchemaV2 模型(新)
let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
// ❌ 无法访问:SchemaV1 模型
// SchemaV1.Note 已消失
}
)
您无法在单个迁移阶段中直接将数据从旧类型转换为新类型。示例:
// ❌ 不可能 - 您无法在一个阶段中完成此操作
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for oldNote in oldNotes {
let newNote = SchemaV2.Note() // ❌ 尚不存在!
newNote.content = oldNote.contentAsAttributedString()
}
}
解决方案 使用两阶段迁移模式(如下所述)。
每个不同的模式版本都必须定义为 VersionedSchema。
import SwiftData
enum NotesSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // 所有模型,即使未更改
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
var content: String // 原始类型
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: String, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model
final class Folder {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .nullify)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
使用场景 更改属性类型(String → AttributedString, Int → String 等)
我们希望将 Note.content 从 String 更改为 AttributedString,但我们无法同时访问旧类型和新类型。
使用一个中间模式版本(V1.1),该版本同时具有两个属性。
// 阶段 1:V1 → V1.1(在旧属性旁边添加新属性)
enum NotesSchemaV1_1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 1, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// 旧属性(将被弃用)
@Attribute(originalName: "content")
var contentOld: String = ""
// 新属性(目标类型)
var contentNew: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, contentOld: String, createdAt: Date) {
self.id = id
self.title = title
self.contentOld = contentOld
self.createdAt = createdAt
}
}
// Folder 和 Tag 未更改(从 V1 复制)
@Model final class Folder { /* 与 V1 相同 */ }
@Model final class Tag { /* 与 V1 相同 */ }
}
// 阶段 2:V1.1 → V2(转换数据,移除旧属性)
enum NotesSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// 从 contentNew 重命名而来
@Attribute(originalName: "contentNew")
var content: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: AttributedString?, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model final class Folder { /* 与 V1 相同 */ }
@Model final class Tag { /* 与 V1 相同 */ }
}
enum NotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV1_1, migrateV1_1toV2]
}
// 阶段 1:轻量级迁移(添加 contentNew)
static let migrateV1toV1_1 = MigrationStage.lightweight(
fromVersion: NotesSchemaV1.self,
toVersion: NotesSchemaV1_1.self
)
// 阶段 2:自定义迁移(转换 String → AttributedString)
static let migrateV1_1toV2 = MigrationStage.custom(
fromVersion: NotesSchemaV1_1.self,
toVersion: NotesSchemaV2.self,
willMigrate: { context in
// 在我们仍能访问 V1.1 模型时转换数据
var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()
// 预取关系以保留它们
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// 转换 String → AttributedString
note.contentNew = try? AttributedString(markdown: note.contentOld)
}
try context.save()
},
didMigrate: nil
)
}
@main
struct NotesApp: App {
let container: ModelContainer = {
do {
let schema = Schema(versionedSchema: NotesSchemaV2.self)
return try ModelContainer(
for: schema,
migrationPlan: NotesMigrationPlan.self
)
} catch {
fatalError("Failed to create container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
使用场景 您有多对多关系(Tags ↔ Notes)
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// 多对多:必须指定反向关系
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ 带有默认值的数组
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
// 多对多:必须指定反向关系
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ 带有默认值的数组
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
在 iOS 17.0 中,如果模型名称按字母顺序排列(例如,Actor ↔ Movie 有效,但 Movie ↔ Person 失败),多对多关系可能会失败。
解决方法 为关系数组提供默认值:
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = [] // ✅ 默认值可防止错误
已在 iOS 17.1+ 中修复
如果您需要在关系上添加额外字段(例如,“此标签是何时添加的?”),请使用显式的联结模型:
@Model
final class NoteTag {
@Attribute(.unique) var id: String
var addedAt: Date // 关系上的元数据
@Relationship(deleteRule: .cascade)
var note: Note?
@Relationship(deleteRule: .cascade)
var tag: Tag?
init(id: String, note: Note, tag: Tag, addedAt: Date) {
self.id = id
self.note = note
self.tag = tag
self.addedAt = addedAt
}
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // 与联结表的一对多关系
var tags: [Tag] {
noteTags.compactMap { $0.tag }
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // 与联结表的一对多关系
var notes: [Note] {
noteTags.compactMap { $0.note }
}
}
使用场景 迁移具有关系的模型以避免 N+1 查询
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// 预取关系(iOS 26+)
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
// 仅获取您需要的属性(iOS 26+)
fetchDesc.propertiesToFetch = [\.title, \.content]
let notes = try context.fetch(fetchDesc)
// 关系已加载 - 没有 N+1
for note in notes {
let folderName = note.folder?.name // ✅ 已在内存中
let tagCount = note.tags.count // ✅ 已在内存中
}
try context.save()
},
didMigrate: nil
)
不进行预取:
- 1 次查询以获取 notes
- N 次查询以获取每个 note 的 folder
- N 次查询以获取每个 note 的 tags
= 1 + N + N 次查询
进行预取:
- 1 次查询以获取 notes
- 1 次查询以获取所有 folders
- 1 次查询以获取所有 tags
= 总共 3 次查询
使用场景 您希望重命名属性而不丢失数据
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String // 原始名称
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
// 从 "title" 重命名为 "heading"
@Attribute(originalName: "title")
var heading: String
}
}
// 迁移计划(轻量级迁移)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
为什么这有效 SwiftData 看到 originalName 并在轻量级迁移期间保留数据。
使用场景 向具有重复项的字段添加 @Attribute(.unique)
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
var name: String // ❌ 不唯一,有重复项
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
@Attribute(.unique) var name: String // ✅ 现在唯一
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// 在添加唯一约束之前去重
let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
var seenNames = Set<String>()
for trip in trips {
if seenNames.contains(trip.name) {
// 重复项 - 删除或重命名
context.delete(trip)
} else {
seenNames.insert(trip.name)
}
}
try context.save()
},
didMigrate: nil
)
}
模拟器行为 在重建时删除数据库,总是看到新的模式
真实设备行为 在更新期间保持持久化数据库,模式必须匹配
// ❌ 错误 - 仅在模拟器中测试
// 您重建 → 模拟器删除数据库 → 全新安装
// 迁移代码从未运行!
// ✅ 正确 - 在真实设备上测试
// 1. 在设备上安装 v1 版本
// 2. 创建示例数据
// 3. 安装 v2 版本(带迁移)
// 4. 验证数据保留
在将任何迁移部署到生产环境之前:
准备代表迁移前状态的测试数据:
使用测试数据运行迁移:
// 在 V1 模式中创建测试数据
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... 填充测试数据 ...
// 运行迁移
let v2Container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
验证:
关键 - 模拟器成功并不能保证生产安全。
# 工作流程:
1. 在真实设备上安装 v1 版本
2. 创建 100+ 条带关系的记录
3. 验证数据存在
4. 安装 v2 版本(覆盖现有应用,不要删除)
5. 启动应用
6. 验证:
- 应用启动不崩溃
- 所有 100+ 条记录仍然存在
- 关系完整
- 新字段已填充
如果您可以访问生产数据:
如果迁移失败,请参阅 axiom-swiftdata-migration-diag 获取调试工具。
import Testing
import SwiftData
@Test func testMigrationFromV1ToV2() throws {
// 1. 创建 V1 数据
let v1Schema = Schema(versionedSchema: SchemaV1.self)
let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)
let context = v1Container.mainContext
let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
context.insert(note)
try context.save()
// 2. 运行迁移到 V2
let v2Schema = Schema(versionedSchema: SchemaV2.self)
let v2Container = try ModelContainer(
for: v2Schema,
migrationPlan: MigrationPlan.self,
configurations: v1Config
)
// 3. 验证数据已迁移
let v2Context = v2Container.mainContext
let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())
#expect(notes.count == 1)
#expect(notes.first?.content != nil) // String → AttributedString
}
您在进行什么更改?
├─ 添加可选属性 → 轻量级 ✓
├─ 添加带默认值的必需属性 → 轻量级 ✓
├─ 重命名属性(使用 originalName) → 轻量级 ✓
├─ 移除属性 → 轻量级 ✓
├─ 更改关系删除规则 → 轻量级 ✓
├─ 添加新模型 → 轻量级 ✓
├─ 更改属性类型 → 自定义(两阶段) ✗
├─ 将可选 → 必需 → 自定义(首先填充空值) ✗
├─ 添加唯一约束(存在重复项) → 自定义(首先去重) ✗
└─ 复杂的关系重构 → 自定义 ✗
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self] // ❌ 错误:缺少 Folder 和 Tag
}
}
// ✅ 正确:包含所有模型
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ✅ 即使未更改
}
}
原因 每个 VersionedSchema 都是数据模型的完整快照,而不是差异。
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ 崩溃:SchemaV1.Note 在此不存在
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
// ✅ 正确:使用 willMigrate 访问旧模型
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note 在此存在
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: nil
)
// ❌ 错误:模拟器成功 ≠ 生产安全
// 重建模拟器 → 数据库被删除 → 全新安装
// 迁移实际上从未运行!
// ✅ 正确:测试迁移路径
// 1. 在真实设备上安装 v1
// 2. 创建数据(100+ 条记录)
// 3. 安装带迁移的 v2
// 4. 验证数据保留
// ❌ 错误:SwiftData 无法推断多对多关系
@Model
final class Note {
var tags: [Tag] = [] // ❌ 缺少反向关系
}
// ✅ 正确:显式反向关系
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ 指定了反向关系
}
模拟器在重建时删除数据库。真实设备在更新期间保持持久化数据库。
影响 迁移错误在模拟器中被隐藏,导致 100% 的生产用户崩溃。
解决方法 在发布之前始终在真实设备上测试。
# 在 Xcode scheme 中,添加参数:
-com.apple.coredata.swiftdata.debug 1
输出 显示迁移期间的实际 SQL 查询
CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE
CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
| 错误 | 可能原因 | 解决方法 |
|---|---|---|
| "Expected only Arrays for Relationships" | 缺少多对多反向关系 | 添加 @Relationship(inverse:) |
| "The model used to open the store is incompatible" | 模式版本不匹配 | 验证迁移计划 schemas 数组 |
| "Failed to fulfill faulting for..." | 关系完整性被破坏 | 迁移期间预取关系 |
| 模式更改后应用启动崩溃 | VersionedSchema 中缺少模型 | 包含所有模型 |
// 1. 定义版本化模式
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }
// 2. 创建迁移计划
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
// 3. 应用到容器
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)
WWDC : 2025-291, 2023-10195
文档 : /swiftdata
技能 : axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration
创建于 2025-12-09 目标 iOS 17+(专注于 iOS 26+ 功能) 框架 SwiftData (Apple) Swift 5.9+
每周安装量
107
代码仓库
GitHub 星标数
601
首次出现
Jan 21, 2026
安全审计
安装于
opencode90
codex85
claude-code83
gemini-cli82
cursor80
github-copilot77
SwiftData schema migrations move your data safely when models change. Core principle SwiftData's willMigrate sees only OLD models, didMigrate sees only NEW models—you can never access both simultaneously. This limitation shapes all migration strategies.
Requires iOS 17+, Swift 5.9+ Target iOS 26+ (features like propertiesToFetch)
SwiftData can migrate automatically for:
@Attribute(originalName:))You need custom migrations for:
String → AttributedString, Int → String)These are real questions developers ask that this skill is designed to answer:
→ The skill shows the two-stage migration pattern that works around the willMigrate/didMigrate limitation
→ The skill explains relationship prefetching and maintaining inverse relationships across schema versions
→ The skill covers explicit inverse relationship requirements and iOS 17.0 alphabetical naming bug
→ The skill shows @Attribute(originalName:) patterns for lightweight migration
→ The skill emphasizes real-device testing and explains why simulator success doesn't guarantee production safety
→ The skill explains SwiftData's design: each VersionedSchema is a complete snapshot, not a diff
→ The skill provides debugging steps for schema version mismatches
→ The skill covers migration testing workflow, real device testing requirements, and validation strategies
CRITICAL This is the architectural constraint that shapes all SwiftData migration patterns.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ CAN access: SchemaV1 models (old)
let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
// ❌ CANNOT access: SchemaV2 models
// SchemaV2.Note doesn't exist yet
},
didMigrate: { context in
// ✅ CAN access: SchemaV2 models (new)
let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())
// ❌ CANNOT access: SchemaV1 models
// SchemaV1.Note is gone
}
)
You cannot directly transform data from old type to new type in a single migration stage. Example:
// ❌ IMPOSSIBLE - you can't do this in one stage
willMigrate: { context in
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
for oldNote in oldNotes {
let newNote = SchemaV2.Note() // ❌ Doesn't exist yet!
newNote.content = oldNote.contentAsAttributedString()
}
}
Solution Use two-stage migration pattern (covered below).
Every distinct schema version must be defined as a VersionedSchema.
import SwiftData
enum NotesSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ALL models, even if unchanged
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
var content: String // Original type
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: String, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model
final class Folder {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .nullify)
var notes: [Note] = []
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
Use when Changing property type (String → AttributedString, Int → String, etc.)
We want to change Note.content from String to AttributedString, but we can't access both old and new types simultaneously.
Use an intermediate schema version (V1.1) that has BOTH properties.
// Stage 1: V1 → V1.1 (Add new property alongside old)
enum NotesSchemaV1_1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 1, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// OLD property (to be deprecated)
@Attribute(originalName: "content")
var contentOld: String = ""
// NEW property (target type)
var contentNew: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, contentOld: String, createdAt: Date) {
self.id = id
self.title = title
self.contentOld = contentOld
self.createdAt = createdAt
}
}
// Folder and Tag unchanged (copy from V1)
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}
// Stage 2: V1.1 → V2 (Transform data, remove old property)
enum NotesSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Renamed from contentNew
@Attribute(originalName: "contentNew")
var content: AttributedString?
var createdAt: Date
@Relationship(deleteRule: .nullify, inverse: \Folder.notes)
var folder: Folder?
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = []
init(id: String, title: String, content: AttributedString?, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.createdAt = createdAt
}
}
@Model final class Folder { /* same as V1 */ }
@Model final class Tag { /* same as V1 */ }
}
enum NotesMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV1_1, migrateV1_1toV2]
}
// Stage 1: Lightweight migration (adds contentNew)
static let migrateV1toV1_1 = MigrationStage.lightweight(
fromVersion: NotesSchemaV1.self,
toVersion: NotesSchemaV1_1.self
)
// Stage 2: Custom migration (transform String → AttributedString)
static let migrateV1_1toV2 = MigrationStage.custom(
fromVersion: NotesSchemaV1_1.self,
toVersion: NotesSchemaV2.self,
willMigrate: { context in
// Transform data while we still have access to V1.1 models
var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()
// Prefetch relationships to preserve them
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
let notes = try context.fetch(fetchDesc)
for note in notes {
// Convert String → AttributedString
note.contentNew = try? AttributedString(markdown: note.contentOld)
}
try context.save()
},
didMigrate: nil
)
}
@main
struct NotesApp: App {
let container: ModelContainer = {
do {
let schema = Schema(versionedSchema: NotesSchemaV2.self)
return try ModelContainer(
for: schema,
migrationPlan: NotesMigrationPlan.self
)
} catch {
fatalError("Failed to create container: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Use when You have many-to-many relationships (Tags ↔ Notes)
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self, Tag.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Array with default value
init(id: String, title: String) {
self.id = id
self.title = title
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
// Many-to-many: MUST specify inverse
@Relationship(deleteRule: .nullify, inverse: \Note.tags)
var notes: [Note] = [] // ✅ Array with default value
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order (e.g., Actor ↔ Movie works, but Movie ↔ Person fails).
Workaround Provide default values for relationship arrays:
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = [] // ✅ Default value prevents bug
Fixed in iOS 17.1+
If you need additional fields on the relationship (e.g., "when was this tag added?"), use an explicit junction model:
@Model
final class NoteTag {
@Attribute(.unique) var id: String
var addedAt: Date // Metadata on relationship
@Relationship(deleteRule: .cascade)
var note: Note?
@Relationship(deleteRule: .cascade)
var tag: Tag?
init(id: String, note: Note, tag: Tag, addedAt: Date) {
self.id = id
self.note = note
self.tag = tag
self.addedAt = addedAt
}
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var tags: [Tag] {
noteTags.compactMap { $0.tag }
}
}
@Model
final class Tag {
@Attribute(.unique) var id: String
var name: String
@Relationship(deleteRule: .cascade)
var noteTags: [NoteTag] = [] // One-to-many to junction
var notes: [Note] {
noteTags.compactMap { $0.note }
}
}
Use when Migrating models with relationships to avoid N+1 queries
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
var fetchDesc = FetchDescriptor<SchemaV1.Note>()
// Prefetch relationships (iOS 26+)
fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]
// Only fetch properties you need (iOS 26+)
fetchDesc.propertiesToFetch = [\.title, \.content]
let notes = try context.fetch(fetchDesc)
// Relationships are already loaded - no N+1
for note in notes {
let folderName = note.folder?.name // ✅ Already in memory
let tagCount = note.tags.count // ✅ Already in memory
}
try context.save()
},
didMigrate: nil
)
Without prefetching:
- 1 query to fetch notes
- N queries to fetch each note's folder
- N queries to fetch each note's tags
= 1 + N + N queries
With prefetching:
- 1 query to fetch notes
- 1 query to fetch all folders
- 1 query to fetch all tags
= 3 queries total
Use when You want to rename a property without data loss
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
var title: String // Original name
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Note.self]
}
@Model
final class Note {
@Attribute(.unique) var id: String
// Renamed from "title" to "heading"
@Attribute(originalName: "title")
var heading: String
}
}
// Migration plan (lightweight migration)
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
Why this works SwiftData sees originalName and preserves data during lightweight migration.
Use when Adding @Attribute(.unique) to a field that has duplicates
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
var name: String // ❌ Not unique, has duplicates
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Trip.self]
}
@Model
final class Trip {
@Attribute(.unique) var id: String
@Attribute(.unique) var name: String // ✅ Now unique
init(id: String, name: String) {
self.id = id
self.name = name
}
}
}
enum TripMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Deduplicate before adding unique constraint
let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())
var seenNames = Set<String>()
for trip in trips {
if seenNames.contains(trip.name) {
// Duplicate - delete or rename
context.delete(trip)
} else {
seenNames.insert(trip.name)
}
}
try context.save()
},
didMigrate: nil
)
}
Simulator behavior Deletes database on rebuild, always sees fresh schema
Real device behavior Keeps persistent database across updates, schema must match
// ❌ WRONG - only testing in simulator
// You rebuild → simulator deletes database → fresh install
// Migration code never runs!
// ✅ CORRECT - test on real device
// 1. Install v1 build on device
// 2. Create sample data
// 3. Install v2 build (with migration)
// 4. Verify data preserved
Before deploying any migration to production:
Prepare test data representing pre-migration state:
Run migration with test data:
// Create test data in V1 schema
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... populate test data ...
// Run migration
let v2Container = try ModelContainer(
for: Schema(versionedSchema: SchemaV2.self),
migrationPlan: MigrationPlan.self
)
Verify:
CRITICAL - Simulator success does not guarantee production safety.
# Workflow:
1. Install v1 build on real device
2. Create 100+ records with relationships
3. Verify data exists
4. Install v2 build (over existing app, don't delete)
5. Launch app
6. Verify:
- App launches without crash
- All 100+ records still exist
- Relationships intact
- New fields populated
If you have access to production data:
See axiom-swiftdata-migration-diag for debugging tools if migration fails.
import Testing
import SwiftData
@Test func testMigrationFromV1ToV2() throws {
// 1. Create V1 data
let v1Schema = Schema(versionedSchema: SchemaV1.self)
let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)
let context = v1Container.mainContext
let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
context.insert(note)
try context.save()
// 2. Run migration to V2
let v2Schema = Schema(versionedSchema: SchemaV2.self)
let v2Container = try ModelContainer(
for: v2Schema,
migrationPlan: MigrationPlan.self,
configurations: v1Config
)
// 3. Verify data migrated
let v2Context = v2Container.mainContext
let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())
#expect(notes.count == 1)
#expect(notes.first?.content != nil) // String → AttributedString
}
What change are you making?
├─ Adding optional property → Lightweight ✓
├─ Adding required property with default → Lightweight ✓
├─ Renaming property (with originalName) → Lightweight ✓
├─ Removing property → Lightweight ✓
├─ Changing relationship delete rule → Lightweight ✓
├─ Adding new model → Lightweight ✓
├─ Changing property type → Custom (two-stage) ✗
├─ Making optional → required → Custom (populate nulls first) ✗
├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗
└─ Complex relationship restructure → Custom ✗
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self] // ❌ WRONG: Missing Folder and Tag
}
}
// ✅ CORRECT: Include ALL models
enum SchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Note.self, Folder.self, Tag.self] // ✅ Even if unchanged
}
}
Why Each VersionedSchema is a complete snapshot of the data model, not a diff.
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
// ❌ CRASH: SchemaV1.Note doesn't exist here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
}
)
// ✅ CORRECT: Use willMigrate for old models
static let migrate = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// ✅ SchemaV1.Note exists here
let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
},
didMigrate: nil
)
// ❌ WRONG: Simulator success ≠ production safety
// Rebuild simulator → database deleted → fresh install
// Migration never actually runs!
// ✅ CORRECT: Test migration path
// 1. Install v1 on real device
// 2. Create data (100+ records)
// 3. Install v2 with migration
// 4. Verify data preserved
// ❌ WRONG: SwiftData can't infer many-to-many
@Model
final class Note {
var tags: [Tag] = [] // ❌ Missing inverse
}
// ✅ CORRECT: Explicit inverse
@Model
final class Note {
@Relationship(deleteRule: .nullify, inverse: \Tag.notes)
var tags: [Tag] = [] // ✅ Inverse specified
}
Simulator deletes database on rebuild. Real devices keep persistent databases across updates.
Impact Migration bugs hidden in simulator, crash 100% of production users.
Fix ALWAYS test on real device before shipping.
# In Xcode scheme, add argument:
-com.apple.coredata.swiftdata.debug 1
Output Shows actual SQL queries during migration
CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE
CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
| Error | Likely Cause | Fix |
|---|---|---|
| "Expected only Arrays for Relationships" | Many-to-many inverse missing | Add @Relationship(inverse:) |
| "The model used to open the store is incompatible" | Schema version mismatch | Verify migration plan schemas array |
| "Failed to fulfill faulting for..." | Relationship integrity broken | Prefetch relationships during migration |
| App crashes on launch after schema change | Missing model in VersionedSchema | Include ALL models |
// 1. Define versioned schemas
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }
// 2. Create migration plan
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
}
// 3. Apply to container
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
for: schema,
migrationPlan: MigrationPlan.self
)
WWDC : 2025-291, 2023-10195
Docs : /swiftdata
Skills : axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration
Created 2025-12-09 Targets iOS 17+ (focus on iOS 26+ features) Framework SwiftData (Apple) Swift 5.9+
Weekly Installs
107
Repository
GitHub Stars
601
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode90
codex85
claude-code83
gemini-cli82
cursor80
github-copilot77
SQL查询优化指南:PostgreSQL、Snowflake、BigQuery高性能SQL编写技巧与方言参考
1,100 周安装