[JS] Firebaseの覚書 ⑦ Firestoreの操作まとめ

こういうまとめ方してあったらカカッと理解できたはず…と思う書き方をしてみた。

※Firebaseの覚え書きには連番つけてますが内容は繋がってません。

2022/07/28 V9について追記

V8とV9の書き方の違い

V8はnamespaceだったのがV9でmoduleになったので、書き方がかなり変わっている。

V9のinitializeApp

import { getApp, FirebaseApp } from 'firebase/app'
import { getFirestore, collection, getDocs, Firestore } from 'firebase/firestore';

const app: FirebaseApp = getApp()
const db: Firestore = getFirestore(app);

async function getCities(db) {
  const citiesCol = collection(db, 'cities');
  const citySnapshot = await getDocs(citiesCol);
  const cityList = citySnapshot.docs.map(doc => doc.data());
  return cityList;
}

コレクションとドキュメント

初心者はコレクションとかドキュメントってなんぞやってなりがち。

コンソールでFirestoreを見ると画面が3分割されてます。
このうち左が Database、中央がCollection、右がDocumentです。

保存されるデータがJSON形式だからこういう呼び方らしい。
ドキュメントはすなわちJSONファイル、Collectionはファイルを保存してるフォルダということです。

リファレンスとスナップショット

これもまた独特な言い回しだけど大きく分けると2つしかない。

Reference参照データベース内の場所を表す。
Snapshotデータ Reference.get()の戻り値。
data()でドキュメントのデータが取れる。

保存・変更・削除はReferenceに対して行う。
データの取得はSnapshotに対して行う。

CollectionReference

V8: firebase.firestore.CollectionReference
V9: CollectionReference class

collection()(V8: firestore.collection())の戻り値

// プロパティ
{
  firestore: Firestore,
  id: string,
  parent: DocumentReference.<DocumentData> | null,
  path: string
}

DocumentReference

V8: firebase.firestore.DocumentReference
V9: DocumentReference class

doc() (V8: firestore.doc())の戻り値

// プロパティ
{
  firestore: Firestore,
  id: string,
  parent: CollectionReference,
  path: string
}

QuerySnapshot

V8: firebase.firestore.QuerySnapshot
V9: QuerySnapshot class

getDocs() (V8: CollectionReference.get()) の戻り値

// プロパティ
{
  docs: Array.<QueryDocumentSnapshot>,
  empty: boolean,
  size: number,
  query: Query,
  metadata: SnapshotMetadata
}

QueryDocumentSnapshot

V8: firebase.firestore.QueryDocumentSnapshot
V9: QueryDocumentSnapshot class

QuerySnapshot.docs (V8)の中身

{
  exists: boolean,
  id: string,
  metadata: SnapshotMetadata,
  ref: DocumentReference
}

DocumentSnapshot

V8: firebase.firestore.DocumentSnapshot
V9: DocuemntSnapshot class

DocumentReference get() (V8)の戻り値

// プロパティ
{
  exists: boolean,
  id: string,
  metadata: SnapshotMetadata,
  ref: DocumentReference
}

以下はリファレンスとスナップショットの関係性を図にしてみたもの。

注意ポイントはCollectionReferenceに対してget()したときの戻り値がQuerySnapshotになることかな🤔

ドキュメントの操作

基本的な流れは、リファレンス取得→操作を実行 である。
※以下戻り値がPromiseのものには await が付いている。

リファレンスの取得

ドキュメント指定とコレクション指定の2種類しかない。

V9

import { getApp, FirebaseApp } from 'firebase/app'
import { getFirestore, collection, doc } from 'firebase/firestore';

const app: FirebaseApp = getApp()
const db = getFirestore(app);

// ドキュメント指定
const documentReference = doc(db, `posts/${id}`)

// コレクション指定
const collectionReference = collection(db, 'posts')

V8

// ドキュメント指定
const documentReference = firestore.doc(`posts/${id}`);

// コレクション指定
const collectionReference = firestore.collection('posts');

ドキュメント作成とデータの保存

Firestoreでは必ずデータの保存先となるドキュメントを作成しなければならない。

サンプルで保存するデータを用意しておく。

const data = {
  title: 'サンプルのタイトル',
  content: 'これが本文です'
};

これを setDoc() (V8: DocumentReference.set()) に引数で渡せばデータが保存できる。
ドキュメントが存在しなかった場合は指定したIDで生成してから保存してくれる。

// V9: ドキュメントに保存
import { doc, setDoc } from 'firebase/firestore';

const documentReference = doc(db, 'posts')
await setDoc(documentReference, data)

// V8: ドキュメントに保存
const documentReference = firestore.doc(`posts/${id}`);
await documentReference.set(data);

既存のドキュメントに上書きになる場合、渡したデータのフィールドだけ更新するならオプションを設定しておく。

// V9: 上書きになる場合に渡したデータのフィールドだけ更新する
await setDoc(documentReference, data, { merge: true })

// V8: 上書きになる場合に渡したデータのフィールドだけ更新する
await documentReference.set(data, { merge: true });

コレクションに自動IDでドキュメントを作成してデータを保存する場合は、CollectionReference.add()を使用する。

// V9: 
import { addDoc } from 'firebase/firestore';
const collectionReference = collection(db, 'posts')
await addDoc(collectionReference, data)

// V8: 自動IDで作成して保存
const collectionReference = firestore.collection('posts');
const reference = await collectionReference.add(data);

ドキュメント削除

DocumentReference.delete()を使うだけ。

// V9:
import { doc, deleteDoc } from "firebase/firestore";
await deleteDoc(doc(db, "posts", postId));

// V8:
await documentReference.delete();

データ取得

ドキュメントリファレンスに対してgetDoc(V8: get())を使うとドキュメントスナップショットが得られる。

// V9
const snapshot = await getDoc(doc(db, `posts/${id}`));

// V8
const snapshot = await documentReference.get();

そのドキュメントが存在しているかどうかはexistsプロパティで確認できる。

// 存在しない
if (!snapshot.exists) return;

IDとリファレンスもプロパティで持ってる。

// ドキュメントのID
const id = snapshot.id;

// ドキュメントのReference
const ref = snapshot.ref;

スナップショットにdata()を使うとドキュメントのデータを全取得、
get()でフィールド指定取得ができる。

// データ全部取得
const data = snapshot.data();

// フィールド指定して取得
const name = snapshot.get('title');

データ更新

ドキュメントリファレンスに対してupdate()を使う。

// 更新
await reference.update(data);

ネストされたオブジェクトの更新

Map型の場合はキーを指定すれば該当するフィールドの更新ができる

{
    name: 'Frank',
    favorites: { food: 'Pizza', color: 'Blue', subject: 'recess' },
    age: 12
}

await reference.update({
  'age': 13,
   'favorites.color: 'Red'
})

フィールドの削除

不要なフィールドを削除したくなった時はそのフィールドの値にfirestore.FieldValue.delete()を設定してupdate()する。

const data = {
  oldField: firestore.FieldValue.delete()
}

await reference.update(data);

保存できるデータ型

Type意味
string文字列
number数値
boolean真偽値
mapオブジェクト
array配列
nullNULL
timestamp日時
geopoint位置情報
const docData = {
    stringExample: "Hello world!",
    booleanExample: true,
    numberExample: 3.14159265,
    dateExample: firebase.firestore.Timestamp.fromDate(new Date("December 10, 1815")),
    arrayExample: [5, true, "hello"],
    nullExample: null,
    objectExample: {
        a: 5,
        b: {
            nested: "foo"
        }
    },
    geopointExample: new firebase.firestore.GeoPoint(35.6693094, 139.7013494)
};

Timestamp型はFirestoreのTimestampクラスを使うのが無難だが、そのままnew Date() だけでも保存できる。

サーバータイムスタンプの利用

フィールドの値に FieldValue.serverTimestamp を指定すると、Firestoreのタイムスタンプが保存される。

{
  createdDate: firebase.firestore.FieldValue.serverTimestamp()
}

値のインクリメント・デクリメント

フィールドの値に FieldValue.increment を利用する。
減算はマイナスの値を渡す。

{
   count: firestore.FieldValue.increment(1)
}

配列の結合と削除

配列を値として持っているフィールドに、新しい要素を追加したい場合は
FieldValue.arrayUnionを利用する。

{
  tags: firestore.FieldValue.arrayUnion(firestore.doc('tags/1'))
}

削除は FieldFValue.arrayRemove で出来る。

{
  tags: firestore.FieldValue.arrayRemove(firestore.doc('tags/1'))
}

パーミッションで怒られるケース

以下の条件に当てはまっているとパーミッションエラーになる

  • ルールの設定が間違っている
  • サブコレクションやドキュメントが残っているのに削除を試みた
  • 認証が必要なルールのドキュメントの操作をサーバーサイドレンダリングで試みた

SSRではfirestore.authの認証情報が使えないので、特にreadに対してauthを要求するルールを設定している場合にパーミッションで弾かれて死にやすい。

コレクションの操作

JSONファイルが入ってるフォルダをイメージすると良い

クエリの作成

フィールドに対する条件にマッチするドキュメントを取得したい時に使う。

where(fieldPath, opStr, value)条件指定
orderBy(fieldPath, directionStr)並び替え

条件指定で使えるフィルターオプションは8種類。

  • <
  • <=
  • ==
  • >=
  • >
  • array-contains
  • array-contains-any
  • in

クエリの制限事項

  • 不等価演算子(!=)は利用できない
    →条件を組み合わせて除外するか、否と判断できるフィールドを持たせる
  • 複数のフィールドに対する範囲フィルタは設定できない
  • 論理和(OR)も利用できない
    →OR条件ごとにクエリを作成する
  • array-contains 句はクエリ毎に1つしか設定できない
  • in の比較対象は最大10個まで

インデックスの作成

脳死でクエリを使用した時は大抵次のようなエラーが出る。

FirebaseError: The query requires an index. You can create it here: https://console.firebase.google.com/v1/r/project/…./firestore/indexes?create_composite=….

コレクションに対するクエリはインデックスが作成されてないと使えないのだ。
親切なことにエラーでインデックスの作成先をURLで提示してくれるので、リンクを開いてインデックスを作成すれば良い。

コンソールだとルールの右隣にある。

最大200なので使わないインデックスはきっちり消しといた方が良い。

クエリの実行

以下はpostsコレクションからpublishedフィールドがtrueになっているドキュメントを取得するサンプルコード。

const query = firestore.collection('posts')
                       .where('published', '==', true)
                       .orderBy('createdDate', 'desc');
const querySnapshot = await query.get();

CollectionReference.orderBy() の戻り値がQuery
Query.get()を実行した場合の戻り値がQuerySnapshotとなる。
得られたドキュメントのSnapshotはQuerySnapshot.docsプロパティに含まれている。

クエリドキュメントの処理

以下は上記のpublished:true にマッチしたドキュメントのデータを取得するサンプル。

const posts = querySnapshot.docs.map(doc => {
   return doc.data();
});

単純にデータが欲しいだけならQueryDocumentSnapshot.get()するだけ。

次に「deleted:trueなドキュメントを全部削除したい」場合。

const querySnapshot = await firestore.collection('posts')
                               .where('deleted', '==', true)
                               .orderBy('createdDate', 'desc')
                               .get();

QuerySnapshot.docsにマッチしたDocumentSnapshotが配列で入っていて、DcoumentSnapshotのrefプロパティにDocumentReferenceがあるので、

QuerySnapshot(posts) {
  docs: [
     QueryDocumentSnapshot(post) {
        ref: DocumentReference // ← これにdelete()
     },
     QueryDocumentSnapshot(post) {
        ref: DocumentReference
     }
  ]
}

このリファレンスに対してdelete()を使えばいいんだけれども、
deleteやupdateなどの処理系メソッドは戻り値がPromiseなのでこれをdocs.forEachでやると完了まで時間がかかってしまう。

// やりがちだけどダメな例
querySnapshot.docs.forEach(async doc => {
 await doc.ref.delete();
});

戻り値がPromiseな処理は、docsに対してmapを使って配列生成してからPromise.allで同時処理する。

await Promise.all(
  querySnapshot.docs.map(doc => doc.ref.delete())
);

もっと良いのはバッチを利用することである(後述)

onSnapshotの利用

onSnapshotでイベントリスナーを登録しておくと、set(), update(), delete() などの操作が行われた時にリアルタイムで処理できる。
onSnapshotメソッドがあるのはCollectionReferenceQueryである。

const query = firestore.collection('posts')
                       .orderBy('createdDate', 'desc');

// 登録
const unsubscribe = query.onSnapshot(onNext, ?onError, ?onCompletion);

// 解除
unsubscribe();

イベントリスナーの解除は戻り値の関数を実行する。

コールバックには引数でQuerySnapshotが渡される。
QuerySnapshotのdocChangesメソッドで最後のスナップショット以降に変更されたドキュメントを配列で得ることができる。
(配列の内容はDocumentChangeである)

DocumentChangeはtypeプロパティで変更内容を持っている。

  • added: 追加された
  • modified : 変更があった
  • removed: 削除された

forEach等でDocumentChangeのtypeをチェックすればドキュメント毎に処理を分けることができる

.onSnapshot(querySnapshot => {
  querySnapshot.docChanges().forEach(docChange => {
   if (docChange.type === 'added') {
       // 追加された
     }
     if (docChange.type === 'modified') {
       // 変更があった
     }
     if (docChange.type === 'removed') {
       // 削除された
     }
  });
})

addedはsetなどでドキュメントが作成されたという意味ではなく、スナップショットに該当するドキュメントが追加されたという意味なので、最初のスナップショットが作成された時は全てのドキュメントがaddedタイプで返される。

サンプルコード

以下はPostsコレクションにイベントリスナーを登録して新規追加、更新、削除を処理する例

const posts = [];

// 新着
const unsubscribe = firestore.collection('posts')
  .where('deleted', '==', false)
  .where('publish', '==', true)
  .where('createdDate', '>=', new Date())
  .orderBy('createdDate', 'asc')
  .onSnapshot(querySnapshot => {
     querySnapshot.docChanges().forEach(docChange => {
      const data = change.doc.data();
    if (docChange.type === 'added') {
        posts.unshift(data);
      }
    });
  });

// 更新・削除
const unsubscribe2 = firestore.collection('posts')
  .where('deleted', '==', false)
  .where('publish', '==', true)
  .orderBy('createdDate', 'desc')
  .onSnapshot(querySnapshot => {
     querySnapshot.docChanges().forEach(docChange => {
      const data = change.doc.data();

      if (docChange.type === 'modified') {
        const index = posts.findIndex(
          post => post.id === data.id
        );
        if (index !== -1) {
          posts.splice(index, 1, data);
        }
      }

      if (docChange.type === 'removed') {
        posts = posts.filter(post => post.id !== data.id);
      }
    });
  });

新規追加の検知は「ドキュメントの作成日時がスナップショットの作成日時以降か」を比較するとスナップショット作成時のaddedを回避できる。

ページ処理

表示したいデータが多数ある場合、1ページあたりの表示件数を制限するページ処理が必要。 PHP版の記事Vue版の記事で ページ処理の作り方を書いてるが、最初と最後のページ番号を表示するUIは最大で何ページあるかという数値が必要で、その数値は表示したいデータの総数がないと作れない。

コレクションにあるデータの総数自体は QueryReference.sizeQueryReference.docs.lengthから取れるが、
Firestoreはクエリがドキュメントを返すごとに課金が発生するので、ページ送りのためだけにデータを全件取得するなんてしたら課金がマッハで爆死しかねない。

最大数が少数固定だったり、分散カウンターで総数をフィールドに持っていたりする場合は別だが、そうでない場合のページ送りは「もっと見る」もしくは「無限スクロール」形式で作ることになる。

取得数の制限

CollectionReference.limit()でマッチしたドキュメントの先頭から指定した件数だけ取得できるので、ページ処理したいコレクションの最初の取得で使用する。(位置はOrderByの後)

// 最初の20件取得
const querySnapshot = await firestore.collection('posts')
    .orderBy('createdDate', 'desc')
    .limit(20)
    .get();

クエリカーソルの利用

21件目以降を取得するクエリの為に、QuerySnapshot.docsから一番最後のものを保存しておく。

// ページ処理用に最後のドキュメントを保持
// @type {QueryDocumentSnapshot}
const lastSnapshot = querySnapshot.docs[querySnapshot.docs.length - 1];

続きを読み込むクエリで CollectionReference.startAfter() を設定して、引数で保存しておいた最後のドキュメントのスナップショットを渡す。

// 次の20件
const nextQuerySnapshot = await firestore.collection('posts')
    .orderBy('createdDate', 'desc')
    .startAfter(lastSnapshot)
    .limit(20)
    .get();

以降は、
クエリ結果最後のドキュメントを保持→次のクエリでstartAfterにセット
これを繰り返す。

続きがあるかどうかを先に知ることができないので、一旦読み込んでからドキュメントがないor規定の表示件数より少ない場合はもっと読むボタンを非表示にする。

if (nextQuerySnapshot.docs.length === 0) return; // ドキュメントない
if (nextQuerySnapshot.docs.length > limit) return; // 表示件数より少ない

onSnapshotのページ処理対応

ページ処理する場合、表示してない所にまでイベントリスナーを設定する必要はないので、ページ処理のクエリにonSnapshotを設定する。

async function getPosts(cursorSnapshot) {
  let query;

  if (cursorSnapshot) {
    query = firestore.collection('posts')
      .orderBy('createdDate', 'desc')
      .startAfter(cursorSnapshot)
      .limit(20);
  } else {
    query = firestore.collection('posts')
      .orderBy('createdDate', 'desc')
      .limit(20);
  }
 
  const querySnapshot = await query.get();
  const count = querySnapshot.docs.length;
  let lastSnapshot = null;

  if (count === 0) {
   return { query, count, posts: [], lastSnapshot };
  }

  lastSnapshot = querySnapshot.docs[count - 1];
  
  const posts = querySnapshot.docs.map(doc => {
    return doc.data();
  });
  return { query, count, posts, lastSnapshot };
}

// @type {Array.<Function>}
const unsubscribes = [];

// 最初の読み込み
const init = await getPosts();
unsubscribes.push(init.query.onSnapshot(querySnapshot => { }));

// 次の読み込み
const next = await getPosts(init.lastSnapshot);
unsubscribes.push(next.query.onSnapshot(querySnapshot => { }));

// 解除
unsubscribes.forEach(unsubscribe => unsubscribe());

トランザクション

トランザクションは大量のドキュメントに読み書き操作をする場合に使える。
1回のトランザクションで最大500ドキュメントまで処理できる。

以下は transactionsコレクションのcountフィールドに+1するサンプル

const querySnapshot = await firestore
  .collection('transactions')
  .orderBy('count', 'desc')
  .get();

await firestore.runTransaction(async transaction => {
  /**
   * 先にgetしなければならない
   * @type {Array.<DocumentSnapshot>} snapshots
   */
  const snapshots = await Promise.all(
    querySnapshot.docs.map(doc => transaction.get(doc.ref))
  );

  // カウントの更新する
  await Promise.all(
    snapshots.map(snapshot =>
      transaction.update(snapshot.ref, {
        count: snapshot.get('count') + 1
      })
    )
  );
});

トランザクションの利用条件などはドキュメント参照。

バッチ処理

特にデータを読み込む必要がない場合はバッチでガッとやれる。
トランザクションと同じで最大数は500。

V8: クエリに該当するドキュメントを全部削除する

次のサンプルは前に書いたクエリに該当するドキュメントを全部削除するやつをバッチ化したもの。

const querySnapshot = await firestore.collection('posts')
                               .where('deleted', '==', true)
                               .orderBy('createdDate', 'desc')
                               .get();
/**
 * @type {WriteBatch}
 */
const batch = firestore.batch();

querySnapshot.docs.forEach(doc => {
  batch.delete(doc.ref);
});

await batch.commit();

V9:配列データからドキュメントを生成する

import { getApp, FirebaseApp } from 'firebase/app';
import {
  getFirestore,
  collection,
  writeBatch,
  doc,
  serverTimestamp,
} from 'firebase/firestore';

const data = [
   { id: 1, name: 'Sample 1' },
   { id: 2, name: 'Sample 2'},
   { id: 3, name: 'Sample 3'}
]
const app: FirebaseApp = getApp();
const db = getFirestore(app);
const batch = writeBatch(db);

data.forEach(dat => {
  const ref = doc(collection(db, 'posts'))
   batch.set(ref, {
       ...dat,
        updatedAt: serverTimestamp(),
        createdAt: serverTimestamp(),  // サーバー時間
   });
})

await batch.commit();

参考リンク

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください