Firebase④ Cloud FireStore - クエリ

クエリを発行する

前回(もう3週間前ですね!)の記事では、単一のドキュメントに対するCRUD操作を見てきました。 しかし、一般的なアプリケーションでは複数のデータを条件によって取得する欲求があるはずです。Firestoreがどのようばクエリを発行できるか見ていきましょう。

単純なクエリ

次の例は、すべての記事を返します。

cosnt db = firebase.firestore()
// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef.get()
  .then(querySnapshot => {
    if (querySnapshot.empty) { // querySnapshot.emptyがtrueの場合コレクションにデータが存在しません。
        console.log('結果は空です')
    } else {
      // querySnapshotをループしてデータを取り出します。
      querySnapshot.forEach(doc => {
         // 単一のドキュメントの操作と同じです。
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

コレクションの参照にget()メソッドを利用して、すべてのコレクションを取得することができます。 また、一般的なNoSQL系データベースと異なりFirestoreのクエリ結果はすべて強い整合性をもつことが特徴です。サーバーからドキュメントを取得する場合は 常に最新のデータにアクセスすることが保証されています。

フィルタを利用する

Firestoreでは、SQLデータベースのようにwhere()メソッドを利用することでクエリをフィルタリングすることができます。 where()メソッドは、3つの引数を受け取り、フィルタリングするフィールド、比較演算、値の順に受けれ入れます。 比較演算子には、以下の8つが利用できます。

  • =
  • <
  • <=
  • >
  • >=
  • in
  • array-contains
  • array-contains-any
=(等価演算子)

次の例は、ログイン中のユーザーの記事を取得します。

cosnt db = firebase.firestore()

// ログインしているユーザーの情報を取得します。
const user = firebase.auth().currentUser()
// ユーザードキュメントへの参照を取得
const userRef = db.collection('user').doc(user.uid)

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // auhtorフィールドは参照型です
  .where('auhtor', '==', userRef)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

クエリに.where('auhtor', '==', userRef)が追加されています。これが基本的なwhere()メソッドの使用方法です。

< <= > >=(比較演算子)

比較演算子も同じように利用できます。 次の例は、2020年4月以降の記事を取得します。

cosnt db = firebase.firestore()

// 起点となる日付を作成
// firestoreの日付型はDateオブジェクトで比較できます。
const date = new Date('2020-04')

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // createdAtフィールドは日付型です
  .where('createdAt', '>=', date)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
inクエリ

inクエリはフィールドがいくつかの値のいずれかに等しいドキュメントを取得します。 inクエリは、Firestoreで単純なORクエリを実行するのに適した方法です。

次の例は、記事のタイトルが「Denoとはなにか - 実際につかってみる」「FireBase①」「JavaScript ES2015」の記事を取得します。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 配列で値を渡します
  .where('title', 'in', ['Denoとはなにか - 実際につかってみる', 'FireBase①', 'JavaScript ES2015'])
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

なお、inクエリに渡せる値は10個までという制約があります。

array-contains(配列メンバーシップ)

array_containsは配列型のフィードに対して使用します。 フィールドの配列に値が含まれていた場合、そのドキュメントを返します。 次の例は、JavaScriptというタグが使用されている記事を取得します。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('tags', 'array-contains', 'JavaScript')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

クエリ対象の値が配列内に複数存在する場合でも、ドキュメントは結果に 1 回だけ含まれます。

array-contains-any(配列メンバーシップ)

array-contains-anyは、配列型に対するinクエリです。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('tags', 'array-contains-any', ['JavaScript', 'PHP', 'Firebase'])
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

inクエリと同様、渡せる値は10までの制約があります。

複合クエリ

1回のクエリの中で、複数のwhere()メソッドを呼び出して作成することができます。複合クエリはAND条件として扱われます。

等価演算子=に対する複合クエリ

等価演算子==に対する複合クエリには制限がなく、複数回フィルタをかけることができます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('auhtor', '==', userRef)
  .where('createdAt', '==', new Date())
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
比較演算子に対する複合クエリ

比較演算子に対して複合クエリを使用する場合、1つのフィールドに対するクエリは有効です。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('createdAt', '>=', new Date('2019-04'))
  .where('createdAt', '<', new Date('2020-04'))
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

しかし、複数のフィールドに対して同時に比較演算子を使用することはできません。次のようなクエリはエラーになります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 複数のフィールドに対する比較演算子はエラー!
  .where('createdAt', '>=', new Date('2019-04'))
  .where('rating', '<', 5)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )
等価演算子と比較演算子、配列メンバーシップを同時に利用する

等価演算子と比較演算子、配列メンバーシップを同時に利用するクエリでは、**複合インデックスを作成する必要があります。 例えば、複合インデックスを作成していない状態で次のようなクエリを発行しようとしてみます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  // 等価演算子と比較演算子を同時に利用する
  .where('createdAt', '>=', new Date('2019-04'))
  .where('published', '==', true)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

次のようなエラーが発生してしまいました。

スクリーンショット 20200524 16.39.11.png

このクエリにはインデックスが必要ですという旨のエラーです。 メッセージに示されたURLをクリックすると、コンソールへ移動して自動的に複合インデックスを作成してくれます。

スクリーンショット 20200524 16.42.56.png

inクエリ、配列メンバーシップ

inarray-containsarray-contains-anyは、複合クエリの中で一度だけ使用することができます。

クエリのソート

orderBy()メソッドを使用すると、クエリ結果を並び替えることができます。1回のクエリで複数のフィールドに対してソートをすることができます。 次の例では、作成日の降順、評価の昇順で並び替えます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .order('createdAt', 'desc')
  // ソート順を指定しなかった場合、昇順になります。
  .order('rating')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

なお、 orderBy() メソッドは、指定したフィールドの有無によるフィルタも行います。 指定したフィールドがないドキュメントは結果セットには含まれません。

orderBy()メソッドはwhere()メソッドと組み合わせて使用することができますが、比較演算子を利用する場合には最初の並べ替えは同じフィールドである必要があります。 次のクエリはエラーになります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('createdAt', '>=', new Date('2019-04'))
  // 比較演算子と異なるフィールドでソートしようとするとエラー
  .order('rating')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

さらに、等価演算子を利用して異なるフィールドでソートする際には複合インデックスを作成する必要があります。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .where('published', '==', true)
  // 複合インデックスの作成が必要
  .order('createdAt', 'desc')
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

データの取得数の制限

limit()メソッドを利用すると、データを取得した数だけ取得します。 次の例では、最新の記事上位10件に限って取得をします。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
articleRef
  .order('createdAt', 'desc')
  // 10件だけ取得
  .limit(10)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

ページネーション

Firestoreのクエリを用いてページネーションを行ってみましょう。 limit句は先程紹介しましたが、offset句はサポートしておりません。その代わりには、startAfter()を利用してクエリの開始点を指定することでページネーションを実現します。

startAfter()には、パラメータドキュメントを渡すことができます。つまり、前回実施したクエリの最後のドキュメントを指定すれば、次のページを取得することができます。

cosnt db = firebase.firestore()

// 記事一覧への参照を作成
const articleRef = db.collection('articles')

const result = []
const limit = 10
// 最後のドキュメントを保持しておきます。
let lastDoc
// すべてのドキュメントを取得したかの判定に使用します。
let isFinish = false

articleRef
  .order('createdAt', 'desc')
  .limit(limit)
  .startAfter(lastDoc)
  .get()
  .then(querySnapshot => {
    if (querySnapshot.empty) {
        // 取得したコレクションが空だったらすべてのドキュメントを取得したと判定
        isFinish = true
    } else {
      if (querySnapshot.size < limit) {
          // 取得したコレクションの数がlimitよりも少なければこれ以上データはない
          isFinish = true
      }
      // 最後のドキュメントを取得
      lastdoc = querySnapshot.docs[querySnapshot.docs.length - 1]
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

2ページ以降も、最初のページと同じ条件のクエリを発行する必要があります。 また、明確にページ数を指定するタイプのページネーションは推奨されていません。(3ページ目を取得しようとしても、2ページ目の終わりがわからない) 無限スクロールによるページネーションの実装が推奨されています。

リアルタイムリスナー

Firestoreの大きな特徴の一つとして、リアルタイムリスナーがあります。リアルタイムリスナーは、クライアント側でFirestoreの最新の状態を監視し、変化があった場合には直ちに状態を同期することができます。 リアルタイムリスナーを利用するにはget()メソッドの代わりにonSnapshot()メソッドを利用します。

cosnt db = firebase.firestore()
const articleRef = db.collection('articles')

const result = []
articleRef
  .getonSnapshot()
  .then(querySnapshot => {
    if (querySnapshot.empty) { 
        console.log('結果は空です')
    } else {
      querySnapshot.forEach(doc => {
        result.push({ id: doc.id, ...doc.data() })
      })
    }
   })
  .catch(e => // エラーが発生したとき )

リアルタイムリスナーは、例えばチャットのような機能も簡単に実装することができます。

また、ドキュメントがどのような変更がされたか確認することもできます。

cosnt db = firebase.firestore()
const articleRef = db.collection('articles')

const result = []
articleRef
  .getonSnapshot()
  .then(querySnapshot => {
    snapshot.docChanges().forEach(change {
      if (change.type === "added") {
        console.log('追加されたドキュメント', change.doc.data());
      }
      if (change.type === "modified") {
        console.log('変更されたドキュメント', change.doc.data());
      }
      if (change.type === "removed") {
        console.log('削除されたドキュメント', change.doc.data());
      }
     })
  }

リアルタイムリスナーはユーザー体験を向上させますが、単純なクエリのほうが適している場合もあります。 例えば、ブログなどで記事を見ている最中に(今この瞬間ですね)突然本文の内容が変わったり削除されたりすることを好ましいと思う人は少ないでしょう。

また、先程のページネーションと組み合わせたりするときも注意が必要です。ページ送りをしている最中にデータの並び順が変わった場合、再度同じドキュメント取得してしまったりなどページ付がおかしくなったりすることがあります。

さらに、データが頻繁に更新されるような場合、データが次々と追加されたり入れ替わるさまを眺めるのは楽しいかもしれませんが、バッテリーや通信量の面でユーザーからは不評を得るかもしれません。

この記事をシェアする
Twitterで共有
Hatena

関連記事