【GAS/BigQuery】日付に応じて異なるクエリを実行

# はじめに
この記事は [Apps Script Advent Calendar 2019](https://qiita.com/advent-calendar/2019/gas) の8日目の記事です。

数日前に[【GCP】BigQuery ScriptingとSchedule Queryを組み合わせようとした結果](https://qiita.com/fuku_tech/items/e655fee5c4d22f52cbfb)という記事にて、その日の日付に応じて条件分岐させてバッチ処理を組もうとしたのですが、現時点ではデータの保存までできないことが判明しました。

そこで、前回BigQuery ScriptingとSchedule Queryの組み合わせでは実現できなかったバッチ処理をGASにて実装してみました。

# BigQuery × GASのサンプル
サンプルはこちらのリポジトリにpushしています。
https://github.com/MasashiFukuzawa/bigquery-gas/blob/master/bigquery.js

# 要件
- BigQueryからCloudSQL（MySQL）に直接アクセスしたい（CloudSQL Federationを利用）
  - 参考：[【GCP】Schedule QueryとCloudSQL Federationを組み合わせて超簡単にMySQLデータをBigQueryへコピーする](https://qiita.com/fuku_tech/items/1c0a1d1d1e59cd188e2f)
- 毎月1日〜10日の間はMySQLのusersテーブルからデータを取得
- 毎月11日〜20日の間はMySQLのcommentsテーブルからデータを取得
- 毎月21日〜月末日まではMySQLのshopsテーブルからデータを取得
- 取得したデータはスプレッドシートに出力＋BigQueryの対象テーブルへ上書き保存
- 毎日AM0~1時の間に上記の処理が定期実行される

# 下準備
## 1. スプレッドシートを作成し、シートに紐付くGASプロジェクトを作成
スプレッドシートのタブの「ツール」→「スクリプトエディタ」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/bae4058e-df9d-a630-67ef-bec818774879.png)

## 2. BigQuery APIを有効化する
「リソース」→「Googleの拡張サービス」をクリックすると以下のようなポップアップが表示されるため、BigQuery APIの部分をオンに切り替えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/a6d6b832-c5af-7f02-f758-c4723104609e.png)

## 3. ライブラリの追加
今回は`underscoreGS`というライブラリを使用したいので「Add a library」の欄に`1yzaSmLB2aNXtKqIrSZ92SA4D14xPNdZOo3LQRH2Zc6DK6gHRpRK_StrT`と入れ、「追加」をクリックします。
すると、対象のライブラリが表示されるので、最新バージョンを指定し、Development modeをONにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/1e506956-6c4a-bca3-9867-cb34c3b64e41.png)

## 4. GCPプロジェクトとの連携
「リソース」→「Cloud Platformプロジェクト」をクリックするとポップアップが表示されるので、自分のGCPのプロジェクト番号を記入し、「プロジェクトを設定」をクリックします。
以下のようにOAuth同意画面の設定を求められるので、「こちら」という部分をクリックしてGCPコンソール画面へと遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/fb8a4f3e-3de2-49b6-b6d9-04e9f93f114e.png)

## 5. OAuth同意画面を作成
GCPのコンソールでは、任意のアプリケーション名を入れるだけで良いです。
その後、4.に戻り、再度「プロジェクトを設定」をクリックことで、GASとGCPプロジェクトが関連付けられます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/2fff056c-7624-fe9a-7905-b8f470d4cbba.png)

# GASの実装
## 1. 処理の流れ
簡単に説明すると`execQueryAccordingToDate`関数が実行されると、以下の順で処理が進みます。

1. クエリを実行する日の日付に応じて対象テーブルとクエリをセット
2. BigQueryのジョブを実行（CloudSQL FederationによってMySQLデータを読みに行く）
3. クエリが完了したらジョブの結果を取得
4. 3.で取得した結果をスプレッドシートに出力
5. 3.で取得したデータを1.でセットしたテーブルに保存（この時、テーブルがなければ新規作成）

それではポイントの箇所を見ていきます。

## 2. グローバル変数の宣言
ファイル全体を通して共通的に使用するGCPのプロジェクト番号、データセット名、対象スプレッドシートをあらかじめ指定しておくと便利です。
今回の場合、最終的にトリガーをセットして自動化したいので、スプレッドシートIDを指定する形で対象のスプレッドシートを取得しています。

```js:bigquery.gs
// Your GCP information
var projectId = '<YOUR GCP PROJECT NUMBER>';
var datasetId = 'data_lake';
// Spreadsheet
var ss = SpreadsheetApp.openById('<YOUR SPREADSHEET ID>');
```

## 3. 日付による条件分岐
この部分がBigQueryのUI上だけでは完結しなかったので、今回GASで実装することとなりました。
今回のサンプルは日付によって参照テーブル先を変えるというシンプルな要件としていますが、実際に実務で使用する際はこの部分がもう少し複雑なものになるかと思います。

```js:bigquery.gs
function setMysqlTable() {
  const today = new Date();
  const day   = today.getDate();

  var mysqlTable;
  if (day < 11) {
    mysqlTable = 'users';
  } else if (day > 20) {
    mysqlTable = 'shops';
  } else {
    mysqlTable = 'comments';
  }
  return mysqlTable;
}
```

## 4. CloudSQL Federationを用いたクエリ
このような形でBigQueryからMySQLのデータを読みに行くことができるようになります。

CloudSQL Federationの概要・設定方法・基本文法などについては、[【GCP】Schedule QueryとCloudSQL Federationを組み合わせて超簡単にMySQLデータをBigQueryへコピーする](https://qiita.com/fuku_tech/items/1c0a1d1d1e59cd188e2f)をご参考下さい。

```js:bigquery.gs
function setQuery(mysqlTable) {
  // Read MySQL data directly from BigQuery by using CloudSQL Federation Query.
  const query =
    "SELECT * FROM EXTERNAL_QUERY( \
      'mycurryapp.asia-northeast1.cloudsql-mycurryapp', \
      'SELECT * FROM " + mysqlTable + "' \
    );"
  return query;
}
```

## 5. クエリ完了レスポンスの受信
クエリが完了していない状態で先の処理に進んでもエラーが出てしまう可能性があるため、ここでクエリが終わるまで待つようにしています。

```js:bigquery.gs
// Check on status of the Query Job.
function checkOnQueryJobStatus(queryResults, jobId) {
  var sleepTimeMs = 500;
  while (!queryResults.jobComplete) {
    Utilities.sleep(sleepTimeMs);
    sleepTimeMs *= 2;
    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId);
  }
  return queryResults;
}
```

## 6. クエリにヒットした全データの取得
ここでは、ヒットしたデータをwhileでループしながら取得＋`concat`でデータを連結しています。

```js:bigquery.gs
// Get all the rows of results.
function getAllResults(queryResults, jobId) {
  var rows = queryResults.rows;
  while (queryResults.pageToken) {
    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, {
      pageToken: queryResults.pageToken
    });
    rows = rows.concat(queryResults.rows);
  }
  return rows;
}
```

## 7. スプレッドシートへの出力
クエリにヒットしたデータがある場合は、ヘッダー付きでデータを出力するようにしています。

今回は、データを上書き保存していくという要件だったため、データ出力前にスプレッドシートをまっさらな状態に戻しています。
もし最終行にデータを追加していきたい場合は、`ws.clear()`の部分をなくし、`getRange`で最終行＋1行目のところからデータが入るように指定してあげればOKです。

```js:bigquery.gs
// Clear sheet data and output BigQuery results to sheet.
function outputToSpreadsheet(rows, ws, queryResults) {
  if (rows) {
    ws.clear();

    // Append the headers and return header cols.
    const headers = appendHeader(ws, queryResults);

    // Append the results and return all spreadsheet data.
    const data = appendResults(rows, headers);

    ws.getRange(2, 1, rows.length, headers.length).setValues(data);

    return data;
  } else {
    Logger.log('No rows returned.');
  }
}

function appendHeader(ws, queryResults) {
  const headers = queryResults.schema.fields.map(function(field) {
    return field.name;
  });
  ws.appendRow(headers);
  return headers;
}

function appendResults(rows, headers) {
  const data = new Array(rows.length);
  for (var i = 0; i < rows.length; i++) {
    var cols = rows[i].f;
    data[i] = new Array(cols.length);
    for (var j = 0; j < cols.length; j++) {
      data[i][j] = cols[j].v;
    }
  }
  return data;
}
```

## 8. BigQueryへの保存
保存先のテーブルがあるかを確認した後、対象テーブルへデータをロードしていきます。

データをロードする際に、CSV形式など、[BigQueryが対応している形式](https://cloud.google.com/bigquery/docs/loading-data?hl=ja)に変換する＋[BLOB（バイナリ・ラージ・オブジェクト）](https://ja.wikipedia.org/wiki/%E3%83%90%E3%82%A4%E3%83%8A%E3%83%AA%E3%83%BB%E3%83%A9%E3%83%BC%E3%82%B8%E3%83%BB%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88)にしなければならないことにご注意下さい。

```js:bigquery.gs
function loadDataToBigQuery(data, tableId) {
  if (!targetTableExists(tableId)) {
    createTable(tableId);
  }

  insertData(convertArrayToBlob(data), tableId);
}
```

保存先のテーブル（今回の場合、`mysql_users` / `mysql_comments` / `mysql_shops`）がない場合は、スキーマ定義を基にして新規テーブルを作成します。
スキーマ定義は長くなるので、ここでは記載しませんが、[サンプルのリポジトリ](https://github.com/MasashiFukuzawa/bigquery-gas/blob/master/bigquery.js)の中では定数として`schema.gs`というファイルに切り出しています。

```js:bigquery.gs
function targetTableExists(tableId) {
  const tableInfo = BigQuery.Tables.list(projectId, datasetId);

  if (tableInfo.totalItems === 0) {
    return false;
  }

  const tables = tableInfo.tables.map(function(table){
    return table.tableReference.tableId;
  });

  if (tables.indexOf(tableId) !== -1) {
    return true;
  } else {
    return false;
  }
}

function createTable(tableId) {
  const table = {
    tableReference: {
      projectId: projectId,
      datasetId: datasetId,
      tableId: tableId
    },
    schema: {
      fields: setBigQuerySchema(tableId)
    }
  };
  table = BigQuery.Tables.insert(table, projectId, datasetId);
  Logger.log('Table created: %s', table.id);
}

function setBigQuerySchema(tableId) {
  var bq_schema;
  switch (tableId) {
    case 'mysql_users':
      bq_schema = USERS_SCHEMA;
      break;
    case 'mysql_comments':
      bq_schema = COMMENTS_SCHEMA;
      break;
    case 'mysql_shops':
      bq_schema = SHOPS_SCHEMA;
      break;
  }
  return bq_schema;
}
```

データをcsvに変換した後、BigQueryへのロード用にBLOBにしています。

```js:bigquery.gs
function convertArrayToBlob(values) {
  const contentType   = "text/csv";
  const lineDelimiter = ",";

  var csvStr = '';

  // Prepare array to csv format with underscoreGS module.
  underscoreGS._map(
    values,
    function(row){
      csvStr += row.join(lineDelimiter) + '\n';
    }
  )

  // Get blob data and return.
  return Utilities.newBlob(csvStr, contentType);
}
```

保存先や保存の際の条件を指定し、実際にデータをロードしている部分です。

今回は上書き保存することが要件だったため、`writeDisposition: 'WRITE_TRUNCATE'`としていますが、行を追加する形で保存していきたい場合は`WRITE_APPEND`とするようにして下さい。

```js:bigquery.gs
function insertData(blob, tableId) {
  const job = {
    configuration: {
      load: {
        destinationTable: {
          projectId: projectId,
          datasetId: datasetId,
          tableId: tableId
        },
        skipLeadingRows: 0,
        allowJaggedRows: true,
        allowQuotedNewlines: true,
        writeDisposition: 'WRITE_TRUNCATE' // update the target table
      }
    }
  };
  BigQuery.Jobs.insert(job, projectId, blob);
}
```

# 動作確認
動作確認のため、GASを実行したいと思います。
初回実行時はOAuth認証が必要なので「許可を確認」→「詳細」→「（安全ではないページ）に移動」から認証を勧めていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/ebcdbf3a-14d1-7565-bb1e-76dc7ee5f574.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/b2a0ab02-ebfb-abdf-3516-b5a16a8c0e8f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/232250ec-0e54-102a-dd97-f3d85ae9657f.png)

すると、このような形でデータが取得できていることが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/1e25889d-6483-25cd-f053-c19aadc79806.png)

BigQueryのテーブルにもデータが入ったかどうかも確認すると、きちんと対象のテーブルに保存されていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/0e317d95-3c03-6a08-e56c-a102186dd40a.png)

# GASの定期実行
## 1. スクリプトファイルの画面から「編集」→「現在のプロジェクトのトリガー」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/a18bb897-3536-3967-7bf3-97eabe1b3b34.png)

## 2. `execQueryAccordingToDate`関数を実行する時間をセット
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/1bb6331c-17db-ec69-5c1e-25e213667749.png)

## 3.（余談）スプレッドシートから手動で`execQueryAccordingToDate`関数を呼び出せるようにする
`bigquery.gs`に以下のコードを追加します。

```js:bigquery.gs
function onOpen() {
  const entries = [
    {
      name : "手動でクエリを実行",
      functionName : "execQueryAccordingToDate"
    }
  ];
  ss.addMenu("BigQuery", entries);
};
```

その後、トリガーの設定画面から、スプレッドシート起動時に`onOpen`関数が動くようにトリガーを追加します。
これをセットしておくと、スプレッドシートを開いた時に、スプレッドシートのタブ中に「BigQuery」という新規項目が追加されるようになり、そこから手動で`execQueryAccordingToDate`関数を呼び出せるようになるので何かと便利です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307533/f3b7ca0b-27de-08f9-0212-183a10f458da.png)

# まとめ
- BigQuery上では難しい条件分岐を含んだクエリの定期実行をGASとBigQuery APIで実現しました。
- GAS経由でもCloudSQL Federationは問題なく使用できました。
- コードのサンプルは[こちらのリポジトリ](https://github.com/MasashiFukuzawa/bigquery-gas/blob/master/bigquery.js)にpushしています。

# 参考URL
- [bigquery-gas](https://github.com/MasashiFukuzawa/bigquery-gas/blob/master/bigquery.js)
- [【GCP】Schedule QueryとCloudSQL Federationを組み合わせて超簡単にMySQLデータをBigQueryへコピーする](https://qiita.com/fuku_tech/items/1c0a1d1d1e59cd188e2f)
- [【GCP】BigQuery ScriptingとSchedule Queryを組み合わせようとした結果](https://qiita.com/fuku_tech/items/e655fee5c4d22f52cbfb)
- [BigQueryが対応している形式](https://cloud.google.com/bigquery/docs/loading-data?hl=ja)
- [Google Apps Scriptを使ってスプレッドシートからBigQueryのテーブルを更新する](https://ex-ture.com/blog/2018/06/24/google-apps-script-to-update-bigquery/)
- [SpreadSheetでGoogleAppScriptを使ってみた(データロード)](https://qiita.com/ys_tydy/items/5096ac2f8f770231408b)