Xcode
iOS
Swift
Firebase
Firestore

[Swift] Firebase Cloud FirestoreのTransactionでSubCollectionを更新する


はじめに

開発しているアプリにクーポン機能を入れることになり、TransactionでSubCollectionを更新する方法が出てこなったのでその方法と、その際にハマったことを共有したいと思います。

(手探りだったのでもっと良い方法や間違いがありましたら教えてください...)


やりたいこと

そのときの状況を説明した方がわかりやすいかと思うので軽く説明します。


クーポン機能の実装

↓仕様はこちら

・使用したユーザーにポイントを付与する

・同じクーポンは一度しか使えない

クーポンは同時に更新がかかる可能性があるためTransactionを使用します。

Transactionってなんぞやって思った方はこちら

クーポンは各ユーザーごとに一度しか使えないのでCouponのSubCollectionのUsersに自分がいるかどうかで判定をするためにクーポン使用時にSubCollectionにUserを保存します。


登場人物(DB)

// User

[
"name": Boo,
"point": 0,
"createdAt": FIRTimestamp: seconds=1555408365 nanoseconds=64000000>,
"updatedAt": FIRTimestamp: seconds=1555408365 nanoseconds=64000000>
]

// Coupon

[
"point": 500,
"users": [] // このUserにクーポンを使用したUserを保存する
"createdAt": FIRTimestamp: seconds=1555408137 nanoseconds=664000000>,
"updatedAt": FIRTimestamp: seconds=1555467699 nanoseconds=701000000>
]


TransactionでSubCollectionを保存する

TransactionでCouponのSubcollectionであるUsersを更新するにはCouponのDocumentReferenceとは別で考える必要があります。

今回の場合はUsersのDocumentReferenceは以下のようになります。

let usersReference: DocumentReference = Firestore.firestore().collection("version").document("1").collection("coupon").document("OqX0FUetRXaOBrQrn9Yz").collection("users").document()

では実際にTransactionで更新してみます。

// 更新するUserのDocumentReference

let userReference: DocumentReference = Firestore.firestore().collection("version").document("1").collection("user").document("Rdw5F1tIPCcLYYKhbBjH")

// 更新するCouponのDocumentReference
let couponReference: DocumentReference = Firestore.firestore().collection("version").document("1").collection("coupon").document("OqX0FUetRXaOBrQrn9Yz")

// 更新するCouponが持っているサブコレクションのUsersのDocumentReference
let usersReference: DocumentReference = Firestore.firestore().collection("version").document("1").collection("coupon").document("OqX0FUetRXaOBrQrn9Yz").collection("users").document()

Firestore.firestore().runTransaction({ (transaction, errorPointer) -> Any? in
do {
// 更新するCoupon
let coupon: DocumentSnapshot = try transaction.getDocument(couponReference)
// クーポンを使用したUserに付与するポイントをCouponから取得する
let point: Int = coupon.data()!["point"] as? Int ?? 0

// 更新するUser
let user: DocumentSnapshot = try transaction.getDocument(userReference)
// ユーザーが現在持っているポイントをUserから取得する
let toPoint: Int = userSnapshot.data()!["point"] as? Int ?? 0

// Userの持っているポイントにCouponのポイントを加算して更新する
transaction.setData(["point" : toPoint + point], forDocument: userReference, merge: true)

// CouponのUsersに使用したUserを保存する
transaction.setData(userSnapshot.data()!, forDocument: usersReference, merge: true)
} catch (let error) {
print(error)
}
return nil
}, completion: { (_, error) in
if let error = error {
print(error)
return
}
})


実装結果

一見うまくいきそうですが

Every document read in a transaction must also be written in that transaction.

このようなエラーが出力されます。どのようなエラーかというと、

トランザクションで読み取られるすべての文書はそのトランザクションに書き込まれる必要があります。

ということみたいです。

もう一度先程のコードを見返してみると


let coupon: DocumentSnapshot = try transaction.getDocument(couponReference)
// クーポンを使用したUserに付与するポイントをCouponから取得する
let point: Int = coupon.data()!["point"] as? Int ?? 0

付与するポイントを取得するためにtransactionからCouponのDocumentReferenceを取得しています。

// CouponのUsersに使用したUserを保存する

transaction.setData(userSnapshot.data()!, forDocument: usersReference, merge: true)

SubCollectionであるusersは保存していますが、親のCouponを保存してないことがわかるかと思います。

エラーの原因はこれです。

では親のCouponも保存する処理を入れてみます。

// CouponのupdatedAtを更新する

transaction.setData(["updatedAt" : FieldValue.serverTimestamp()], forDocument: couponReference, merge: true)

こちらの処理を追加します。


再度実行してみる


Coupon

スクリーンショット 2019-04-18 19.17.40.png


User

スクリーンショット 2019-04-18 19.18.20.png

このようにCouponのSubCollectionであるUsersにUserが保存され、Userのポイントも更新されています。


まとめ


TransactionでSubCollectionを更新する場合


  1. 親のDocumentReferenceとは別で考える

  2. runTransaction内で親のDocumentReferenceを取得している場合、親のDocumentも更新する必要がある