距離計算をより短時間で。Supabase Database Functionsのおかげで実現できた！

# 概要
現在、Flutterのアプリケーション作成を行っています。

アプリケーションの機能の中で、現在地との距離を表示する箇所がありました。
以下が実際のアプリケーションのスクリーンショットです。赤枠で囲った箇所です。

距離を表示するための実装方法やデータベースのテーブル構成を考える中で、
**Database Functions**についても学ぶ機会となりましたので、まとめてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/5377e676-5cf5-431c-98ff-df15ee374a77.png)

## 利用技術
- 言語：Dart（Flutter）
- Database：Supabase

https://flutter.dev/

https://supabase.com/

## 処理の流れ
やりたかったことを説明していきます。

#### 1. 位置情報取得
まずは自分の現在地、位置情報（緯度・経度）を取得する必要があります。
Google Mapとの連携する場合は、Google CloudのAPIを呼び出す必要がありますが、
現在はそこまでのステップには至っていないので、まずは位置情報取得だけ行います。

位置情報の取得には、以下のFlutterパッケージを使いました。

https://pub.dev/packages/geolocator

また皆さんも一度はご覧になったことがあるかと思いますが、
アプリからデバイスの位置情報へのアクセスをしてもいいですか？と聞かれたことがあるかと思います。

このパッケージを利用してアプリケーションに組み込むと、ポップアップが表示されるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/812a6af1-f444-43e3-87df-b3d0c991d0c1.png)

権限確認と現在地取得は以下実装で完結します。

```dart
  // 位置情報の権限チェック
  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      state = state.copyWith(isLoading: false, error: '位置情報の権限が拒否されました。');
      return;
    }
  }

  // 位置情報の取得
  final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
```

#### 2. 各店舗の位置情報取得
各店舗の情報（ここでは緯度と経度）をデータベースのテーブルに用意してあります。
条件ついて、
緯度経度で範囲指定、といったことができそうですが、今全件取得とします。

```dart
List<StoreMobileOrder> _stores = [];

// データ取得
final SupabaseClient supabase = Supabase.instance.client;
final response = await supabase.from('stores').select();

// 画面表示のため、取得結果をStoreMobileOrderモデルに設定
final List<dynamic> data = response as List<dynamic>;
_stores = data.map((json) => StoreMobileOrder.fromJson(json)).toList();
```

#### 3. 現在地との距離計算
1で取得した現在地と、2で取得した各店舗の位置から、距離を算出します。
距離算出において、ループする点やソート処理がでてきたタイミングで、
別の方法があると思い始めましたが、進みました。
```dart
for (final store in _stores) {
   final distanceInMeters = Geolocator.distanceBetween(
     _currentPosition!.latitude,
     _currentPosition!.longitude,
     store.latitude,
     store.longitude,
   );

   // メートルからキロメートルに変換して小数点第1位までを表示
   store.distance = (distanceInMeters / 1000).toStringAsFixed(1);
 }
 // 距離順にソート
 _stores.sort((a, b) => double.parse(a.distance).compareTo(double.parse(b.distance)));

 // 先頭10件に絞る
 _stores = _stores.take(10).toList();
```

#### 4. 画面に表示
3の結果を表示する処理を追加します。
実際はもっと長いですが、関係する部分に絞ると以下の通りです。

> store.formattedDistanceKm,
```dart
SliverList(
    delegate: SliverChildBuilderDelegate((context, index) {
      final store = _stores[index];
      return Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _RowSelectButtonAndDistance(
                    store: store,
                    distance: store.formattedDistanceKm,
                    onSelected: () {
                      // Providerに選択した店舗を設定
                      ref.read(selectedStoreProvider.notifier).state = store;
                      Navigator.of(context).pushNamed('/togo-inside');
                    },
                  ),
                ],
              ),
            ),
          ),
        ],
      );
    }, childCount: _stores.length),
  ),
```
**※ `_RowSelectButtonAndDistance`**
「選択する」というボタンと距離の表示に関するWidgetです。

## 動作確認
想像通りでしたが、時間がかかります。

> 3. 現在地との距離計算

ループ処理内で算出する処理をしているので時間がかかります。
店舗のデータは10件しかないのに、**5～10秒はかかっていました。**

このくらい時間がかかると、ローディングアニメーションではなく、プログレスバーを使う方が妥当な領域です。

https://note.com/miki_s_id/n/n714707276c0b

データが100件になったら、アプリを閉じたくなるほどの時間がかかってしまいます。

**距離算出のための方法を考える必要がありました。**

方法を調べる中で、
**`Database Functions`が良さそうと分かりました。**

## rpc(Remote Procedure Calls)を用いたデータ取得
冒頭に記載した通り、Supabaseに`Database Functions`という機能があります。
存在は知っていましたが、これまで触ったことがありませんでした。

https://supabase.com/docs/guides/database/functions?queryGroups=language&language=dart

以下はDart関連のページです。

https://supabase.com/docs/reference/dart/rpc

#### 簡単なものから作成
まずは構造理解のために、以下関数を作成してみました。
実際は以下のような関数は作成せず、テーブルから直接SELECTしますが、実験です。
```sql
CREATE OR REPLACE FUNCTION get_all_stores()
RETURNS SETOF stores
LANGUAGE sql
AS $$
  SELECT * FROM stores ORDER BY store_number;
$$;
```
コード上で呼び出すときは以下のように書きます。
```dart
final response = await supabase.rpc('get_all_stores', params: {});
```

#### 作成した関数
呼び出すために必要な情報は以下の3つです。
- 現在地の緯度 
- 現在地の経度
- 距離制限（仮で15kmに設定。これほど遠い店舗に対してオーダーすることはないはず）

算出した距離に関係するクエリが以下部分です。これはClaudeに聞いてみたコードです。

`6371`は地球の半径なので、数学物理あたりの計算を活用した算出方法です。
```sql
6371 * acos(
        cos(radians(lat)) * cos(radians(s.latitude)) *
        cos(radians(s.longitude) - radians(lng)) +
        sin(radians(lat)) * sin(radians(s.latitude))
      ) AS distance_km
```
関数の内容は、以下の通りです。
```sql
CREATE OR REPLACE FUNCTION get_nearby_stores(
  lat DOUBLE PRECISION, 
  lng DOUBLE PRECISION, 
  radius_km DOUBLE PRECISION
)
RETURNS TABLE (
  id INTEGER,
  store_name VARCHAR(50),
  address VARCHAR(100),
  closing_time TIME,
  opening_time TIME,
  latitude DOUBLE PRECISION,
  longitude DOUBLE PRECISION,
  distance_km DOUBLE PRECISION
)
LANGUAGE sql
AS $$
  WITH stores_with_distance AS (
    SELECT 
      s.id,
      s.store_name,
      s.address,
      s.closing_time,
      s.opening_time,
      s.latitude,
      s.longitude,
      6371 * acos(
        cos(radians(lat)) * cos(radians(s.latitude)) *
        cos(radians(s.longitude) - radians(lng)) +
        sin(radians(lat)) * sin(radians(s.latitude))
      ) AS distance_km
    FROM stores s
    WHERE s.closing_date IS NULL OR s.closing_date > CURRENT_DATE
  )
  SELECT 
    id,
    store_name,
    address,
    closing_time,
    opening_time,
    latitude,
    longitude,
    distance_km
  FROM stores_with_distance
  WHERE distance_km < radius_km
  ORDER BY distance_km
  LIMIT 10;
$$;
```
#### 実装を再修正
最初はDartで実装していた距離算出に関する実装が簡潔になりました。

また現在地取得のタイミングも修正しました。
対象画面を表示する前に取得し、その結果をProviderに設定しました。

Providerと書きましたが、正確にはriverpodを使った状態管理です。

https://pub.dev/packages/riverpod

以下は、Providerを経由して現在地を取得する実装です。

```dart
// 現在地の取得
final locationState = ref.read(locationProvider);
if (locationState.position != null) {
// Supabaseから店舗情報を取得
final response = await _supabase.rpc(
  'get_nearby_stores',
  params: {
    'lat': locationState.position?.latitude,
    'lng': locationState.position?.longitude,
    'radius_km': 15.0,
  },
);
final List<dynamic> data = response as List<dynamic>;
_stores = data.map((json) => StoreMobileOrder.fromJson(json)).toList();
```

## 利用した結果と感じたこと

#### 結果

> 実行時のデータは10件ほどでしたが、5～10秒はかかっていました。

データを30件ほどに増やしても、1～2秒ほどに改善されました。
これであれば、ローディングアニメーションのままで問題なさそうです！！（参考記事再掲）

https://note.com/miki_s_id/n/n714707276c0b

#### 感じたこと

SQLの実装経験がある方であれば、進めやすいと思いました。

また、Supabaseのメソッドを使ってテーブル結合して取得することはできますが、
以下の2点から、テーブル結合が必要となる場合は、`Database Functions`を使う方が進めやすいと感じました。（個人的な感想です！）
- 直感的ではない印象（別途記法を理解する必要がある）点
- コード行数が多くなりやすい点（長くなるのであれば、生のSQLの方が見やすいかと）

ちなみに、6つのテーブルを結合したとき以下のように実装します。

今はSELECT項目数が少ないのでそれほど気にならないですが、増えると少し大変になる気がしました。
```dart
  final response =
  await supabase
      .from('products')
      .select('''
      product_id,
      product_name,
      description,
      categories (
        category_id,
        category_name
      ),
      product_temperature_types (
        temperature_types (
          temperature_type_id,
          type_name
        )
      ),
      product_sizes (
        price,
        sizes (
          size_id,
          size_name
        )
      )
    ''')
      .eq('product_id', 1)
      .eq('is_active', true)
      .single(); // 単一行の結果を期待
```

`Database Functions`を用いると、以下の通りスッキリします。
```dart
final response = await _supabase.rpc(
  'get_nearby_stores',
  params: {
    'lat': locationState.position?.latitude,
    'lng': locationState.position?.longitude,
    'radius_km': 15.0,
  },
);
```

**複数テーブルに跨るデータ追加したいとき**
rpcを使えば、複数テーブルにデータを投入する際のトランザクション管理も適切かつシンプルに構築できそうです。

Dart側のコード側で検討するのは最善ではないしょうし、大変です。
(テーブルAに追加して、テーブルBのデータ投入に失敗したら・・・ロールバック・・・)


rpc側でトランザクションを含めてすべて管理してしまえば、そのあたりもサクッとできそうです。

## この後の流れ
#### データ取得タイミングとローカル保存と`Database Functions`の更なる活用
本記事冒頭に画像を載せたアプリケーションに関して、画面の遷移パターンや機能を見ていると、
毎回データベースから呼び出すのではなく、
端末にデータを保存して、一定時間経過したら再取得といった処理になっているはずです。

またまだやれることは多そうなので、１つずつやっていければと思います。

#### PostGIS: Geo queries
今回作成した関数では、距離算出を原始的なやり方で行っていました。
Supabaseのドキュメントで以下情報を見つけました。

https://supabase.com/docs/guides/database/extensions/postgis?queryGroups=language&language=dart

地理情報に特化したSupabaseの拡張機能の一つで、緯度経度から距離を算出するできる拡張機能のようです。
今回は触れていないのですが、この後の改修内容になりそうなるかもしれないので、取り組んでみたいと思います。

ありがとうございました。