🕌

Firestore のスキーマ移行メモ

に公開

TL;DR
• 既存ドキュメントに互換性を保つため、まずは「デュアルライト(departmentName と旧 name の両方を書き込む)」で移行を開始。
• 新規作成は document() で先にドキュメントIDを確保し、そのIDを departmentId フィールドにも保存。
• department_memberships.departmentId は「departments のドキュメントID」に統一。
• 段階的に旧 name の書き込みを停止 → 旧キーの読み取り互換も削除して最終的に一本化。

背景と課題
• モデル/コレクション間でフィールド名が不統一(Department.name と DepartmentMembership.departmentName)。
• ドキュメントIDをクエリや参照で使いたいが、@DocumentID var id は「フィールドとしては保存されない」ため、ドキュメント内に departmentId を冗長保存したい。
• 既存ユーザーのデータは name で保存されているため、いきなり departmentName のみを読む/書くと互換性が壊れる。

取ったアプローチ(安全・最小変更)

  1. 新規作成では「ドキュメントIDを先に確保」して保存
    • addDocument は Firestore 側でIDが発行されるため、同時にデータへ埋め込めない。
    • document() で先に参照を作り、ref.documentID を departmentId に入れてから setData で保存。

  2. デュアルライトでスムーズに移行
    • 保存時は departmentName(新)と name(旧)の両方へ書く。
    • 読み取り互換を入れておけば、既存データと新規データが混在しても表示が壊れない。

  3. Membership の参照整合性を担保
    • department_memberships.departmentId は「departments のドキュメントID(= ref.documentID)」に統一。
    • これで部門→メンバー/メンバー→部門の参照が一貫する。

なぜ「古い name キー」をすぐ消さないのか
• 既存の Firestore ドキュメントは name で保存されているため、互換読み込みが無いと「表示が空になる/クラッシュ」などのリスクがある。
• 既に配布済みのアプリやオフラインキャッシュが name を期待している可能性がある。
• 段階的に
• 書くのは新旧両方(デュアルライト)
• 既存をバックフィル(name → departmentName、departmentId 追加)
• 旧 name の書き込み停止 → 旧キー読み取り削除
の流れが安全。

実装の要点(MainViewModel)

  1. 部門を作成する(ID先取り + デュアルライト + departmentId 追加)

func createDepartment(name: String, description: String) async throws {
guard let user = self.user else {
throw NSError(domain: "DepartmentError", code: 10,
userInfo: [NSLocalizedDescriptionKey: "レベル10以上のユーザーのみ部門を作成できます"])
}
guard let userId = self.userId else {
throw NSError(domain: "DepartmentError", code: 4,
userInfo: [NSLocalizedDescriptionKey: "ユーザー情報が見つかりません"])
}

// 1) ドキュメントIDを先に確保
let ref = db.collection("departments").document()
let departmentId = ref.documentID
let now = Date()

// 2) デュアルライト + departmentId を含めて保存
let departmentData: [String: Any] = [
    "departmentId": departmentId,
    "departmentName": name,   // 新キー
    "name": name,             // 旧キー(移行期間のみ書く)
    "description": description,
    "creatorName": user.nickname,
    "creatorId": userId,
    "createdAt": Timestamp(date: now),
    "memberCount": 1
]
try await ref.setData(departmentData) // addDocumentではなくsetData

// 3) メンバーシップも保存(部門ドキュメントIDを参照)
let membershipId = "\(userId)_\(departmentId)"
let membershipData: [String: Any] = [
    "userId": userId,
    "departmentId": departmentId,  // 部門ドキュメントID
    "departmentName": name,
    "joinedAt": Timestamp(date: now)
]
try await db.collection("department_memberships").document(membershipId).setData(membershipData)

// 4) 再読み込み
await loadDepartments()
await fetchUserMemberships()

}

ポイント
• document() でIDを先取り → ref.documentID を departmentId に保存可能。
• モデルの整備が完了するまで、辞書保存が最短で確実(後で setData(from:) に戻してOK)。

  1. 部門に参加する(ID指定)

func joinDepartment(_ departmentId: String) async throws {
guard let userId = self.userId else { return }

// 表示名は departmentName を優先
let deptName = departments.first { $0.id == departmentId }?.departmentName ?? ""

// メンバーシップ保存(トップレベルの department_memberships)
let membershipId = "\(userId)_\(departmentId)"
let data: [String: Any] = [
    "userId": userId,
    "departmentId": departmentId,
    "departmentName": deptName,
    "joinedAt": Timestamp(date: Date())
]
try await db.collection("department_memberships").document(membershipId).setData(data)

// 部門のメンバー数を更新
try await db.collection("departments").document(departmentId)
    .updateData(["memberCount": FieldValue.increment(Int64(1))])

// 最新の所属情報を再取得
await fetchUserMemberships()

}

  1. 部門に参加する(Department 型)

func joinDepartment(_ department: Department) async throws {
guard let departmentId = department.id else {
throw NSError(domain: "DepartmentError", code: 2,
userInfo: [NSLocalizedDescriptionKey: "部門IDが無効です"])
}
guard let userId = self.userId else {
throw NSError(domain: "DepartmentError", code: 5,
userInfo: [NSLocalizedDescriptionKey: "ユーザーIDが見つかりません"])
}

let alreadyJoined = userDepartments.contains { $0.departmentId == departmentId }
guard !alreadyJoined else {
    throw NSError(domain: "DepartmentError", code: 3,
                  userInfo: [NSLocalizedDescriptionKey: "既にこの部門に参加しています"])
}

let membershipId = "\(userId)_\(departmentId)"
try await db.collection("department_memberships").document(membershipId).setData([
    "userId": userId,
    "departmentId": departmentId,
    "departmentName": department.departmentName,
    "joinedAt": Timestamp(date: Date())
])

try await db.collection("departments").document(departmentId).updateData([
    "memberCount": FieldValue.increment(Int64(1))
])

loadDepartments()
await fetchUserMemberships()

}

なぜ addDocument ではなく document().setData か
• addDocument は「書き込み後にIDが返る」ため、同時に departmentId をデータへ埋め込めない(後から update が必要)。
• document() なら「クライアント側でIDを先に確保」できるので、departmentId を含めて一度で保存できる。

コレクション名は変えない方が良い
• Firestore は「コレクション名のリネーム」ができないため、実質的には全コピー+クエリパス切替+ルール/索引/トリガー更新など大規模移行になる。
• 今回の要件はコレクション名を変えなくても達成できるため、departments のままが現実的。

移行ステップのロードマップ
• Step 1: 上記の「新規データはデュアルライト」+「departmentId 保存」を適用(完了)。
• Step 2(任意・推奨): 既存ドキュメントにバックフィル
• name → departmentName をコピー
• departmentId が無いものに documentID を埋める
• Step 3: デュアルライト停止(name の書き込みを外す)
• Step 4: 旧キー読み取り互換を削除(最終的に departmentName のみへ)

テスト観点チェックリスト
• 新規作成した部門ドキュメントに以下が含まれる
• departmentId = ドキュメントID
• departmentName と name が同じ値
• department_memberships に
• departmentId = 部門ドキュメントID
• departmentName = 表示名
• 参加後に departments.memberCount が +1 される
• 既存の部門データが UI で正しく表示される(旧 name しか無いドキュメントも崩れない)

ここまでのまとめ
• 破壊的変更(即日 name 廃止)は避け、まずは「デュアルライト + ID先取り保存」で安全に移行を開始。
• department_memberships は departmentId を「departments のドキュメントID」に統一し、参照整合性を担保。
• 移行完了後に name の書き込みを止め、最終的に読み取り互換を外して一本化。

この方針なら、現行のユーザーや既存データへの影響を最小にしつつ、望むスキーマに段階的に到達できます。必要であれば、Department モデル側(Codable)に「旧 name を読みつつ新 departmentName を優先して書く」実装を追加し、全体の整合性をさらに高めることも可能です。

Discussion