📊

【Flutter】サウナライフで実装した集約クエリ(Firestore Aggregation)を使ったレポート機能

2024/11/29に公開

マップで近くのサウナを簡単に検索できるアプリ「サウナライフ」の開発・運用しています。
集約クエリ(Firestore Aggregation)を使ったレポート機能を解説します。

サウナライフのダウンロードはこちらから👇
iOS版, Android版

サウナライフにはサウナの記録を投稿でき、投稿数を年月ごとにグラフで表示する機能と、施設毎に回数を表示する機能があります。これらは集約クエリ(Firestore Aggregation)を用いて実現しています。

投稿の合計数 施設毎の合計数
サウナライフ サウナライフ

Firestoreの集約クエリ

2022年10月頃からFirestoreに集約クエリが登場し、ドキュメントのcount,sum,averageが取得できるようになりました。

クエリ 概要
count ドキュメント数
sum 特定のクエリに一致する数値の合計値
average 特定のクエリに一致する数値の平均値
typedef Result = ({int count, double sum, double average});


class FetchAggregation extends _$FetchAggregation {
    
    Future<Result> build() async {
        final snap = await FirebaseFirestore.instance
            .collection('aggregation')
            .aggregate(count(), sum('value'), average('value'))
            .get();
        
        final result = (
            count: snap.count ?? 0,
            sum: snap.getSum('value') ?? 0.0,
            average: snap.getAverage('value') ?? 0.0,
        );
        return result;
    }
}

collectionにコレクションパスをセットし、aggregateに取得したい集計クエリをセットします(上限は30個まで)。

sumaverageに指定する文字列は、ドキュメントフィールド名です。取得したいドキュメントフィールド名を指定することで一度に複数の値を取得できて便利です。

また、wherestartAtといったクエリも使えるため、条件に応じて取得が可能です。

where

class FetchAggregation extends _$FetchAggregation {
  
  Future<Result> build() async {
    final snap = await FirebaseFirestore.instance
        .collection('aggregation')
        .where('value', isEqualTo: 3)
        .aggregate(count(), sum('value'), average('value'))
        .get();

    return (
      count: snap.count ?? 0,
      sum: snap.getSum('value') ?? 0.0,
      average: snap.getAverage('value') ?? 0.0,
    );
  }
}
where + 複数フィールド

class FetchAggregation extends _$FetchAggregation {
  
  Future<Result> build() async {
    final snap = await FirebaseFirestore.instance
        .collection('aggregation')
        .where('status', isEqualTo: 0)
        .where('value', isEqualTo: 7)
        .aggregate(count(), sum('value'), average('value'))
        .get();

    return (
      count: snap.count ?? 0,
      sum: snap.getSum('value') ?? 0.0,
      average: snap.getAverage('value') ?? 0.0,
    );
  }
}
where + Filter.or

class FetchAggregation extends _$FetchAggregation {
  
  Future<Result> build() async {
    final snap = await FirebaseFirestore.instance
        .collection('aggregation')
        .where(
          Filter.or(
            Filter('value', isEqualTo: 5),
            Filter('value', isEqualTo: 7),
          ),
        )
        .aggregate(count(), sum('value'), average('value'))
        .get();

    return (
      count: snap.count ?? 0,
      sum: snap.getSum('value') ?? 0.0,
      average: snap.getAverage('value') ?? 0.0,
    );
  }
}
where + Filter.or + 複数フィールド

class FetchAggregation extends _$FetchAggregation {
  
  Future<Result> build() async {
    final snap = await FirebaseFirestore.instance
        .collection('aggregation')
        .where(
          Filter.or(
            Filter('value', isEqualTo: 9),
            Filter('status', isEqualTo: 1),
          ),
        )
        .aggregate(count(), sum('value'), average('value'))
        .get();

    return (
      count: snap.count ?? 0,
      sum: snap.getSum('value') ?? 0.0,
      average: snap.getAverage('value') ?? 0.0,
    );
  }
}
クエリカーソル

class FetchAggregation extends _$FetchAggregation {
  
  Future<Result> build() async {
    final snap = await FirebaseFirestore.instance
        .collection('aggregation')
        .orderBy('value')
        .startAt([9])
        .aggregate(count(), sum('value'), average('value'))
        .get();

    return (
      count: snap.count ?? 0,
      sum: snap.getSum('value') ?? 0.0,
      average: snap.getAverage('value') ?? 0.0,
    );
  }
}

動作と制限事項

公式ドキュメントから重要なところを抜粋しました。

  • リアルタイムリスナーとオフラインクエリは利用できない
    • キャッシュデータは無い、サーバーからの取得に限る
  • 集約処理が60秒以内に解決できない場合はエラーになる
    • 60秒以内に解決できない場合は分散カウンタを使う
  • sum() 集計と average() 集計では、数値以外の値は無視されます。sum() 集計と average() 集計では、整数値と浮動小数点数値のみが考慮される
  • 集計クエリはインデックスエントリから読み取り、インデックス付きフィールドのみを含める
    • 複合インデックスの生成は忘れずに

料金体系

インデックスエントリ0〜1000個に対して1カウントで1回のドキュメントの読み取りとして課金され、1500個の場合は2カウントとのことです[1]。ユーザー固有の集約機能を考えると、そこまでお金はかからないと思いますので積極的に利用して良いと思います。

サウナライフのレポート機能

年毎の投稿合計数の実装は以下の通りです。合計数のみなのでcount()を利用しました。

ユーザーの年間投稿合計数を取得

Future<Result> fetchPostCountOfYearly(
    Ref ref,
    int year,
    String saunnerId,
) async {
    final date = DateTime(year);
    final startedAt = date.startOfYearly;
    final endedAt = date.endOfYearly;

    final snaps = await Future.wait([
      FirebaseFirestore.instance
          .collection(
            '...', // saunnerIdが投稿した公開用コレクションパス
          )
          .orderBy('checkInAt', descending: true)
          .startAfter([endedAt])
          .endAt([startedAt])
          .count()
          .get(),
      FirebaseFirestore.instance
          .collection(
            '...', // saunnerIdが投稿した非公開用コレクションパス
          )
          .orderBy('checkInAt', descending: true)
          .startAfter([endedAt])
          .endAt([startedAt])
          .count()
          .get(),
    ]);
    
    final count = (snaps[0].count ?? 0) + (snaps[1].count ?? 0);
    return (count: count, dateTime: startedAt);
}
ユーザーのこれまでの投稿合計数を1年毎に取得

List<Result> fetchPostCountListOfYearly(
    Ref ref,
    DateTime createdAt,
    String saunnerId,
) {
    final now = DateTime.now();
    final count = 1 + now.year - startDate.year;
    final result = <Result>[];
    for (var i = 0; i < count; i++) {
        final year = startDate.year + i;
        final data = ref.watch(fetchPostCountOfYearlyProvider(year, saunnerId)).valueOrNull;
        if (data == null ||
            (data.dateTime.year < createdAt.year && data.count == 0)) {
          continue;
        }
        result.insert(0, data);
    }
    return result;
}

参考

Firestoreで集計機能を作りたい(Firestore Aggregation)

Firebase Firestore Aggregationを触ってみた
@2024.2.1(木) 【Firebase】GDG Tokyo Monthly Online Tech Talks

サンプルコード

脚注
  1. Cloud Firestore の課金について - 集約クエリ ↩︎

株式会社Never

Discussion