🦔

GASを使ったカスタムバリデーションとBigQueryへのデータロード

2022/03/08に公開

背景・目的

事業のダッシュボードを作るために、スプレッドシートで管理している数字をBigQueryでテーブル化する必要があった。
スプレッドシートは手動で更新しているので、不正なデータが入る可能性が多分にあり、品質が担保されたデータだけを分析基盤に入れたい。

前提

  • 開発環境としてはGCPを使っている
  • GCPは別のチームも使っている

選択肢

当時の自社の状況に照らし合わせたメリデメ比較。

選択肢 メリット デメリット
スプレッドシートをそのまま外部テーブル化 運用がいらず、常に最新のテーブルを提供できる。 IF更新中にクエリが投げられるとエラーになりそう。バリデーションがかけられない。
GCSに配置し、Cloud Functionsで移送 自由にバリデーションをかけられる。GCS配置をトリガーにできるので、運用が楽。 開発必要。Cloud Functionsは別のTMでも使っているので、テスト工数が増え、運用手順などもガチガチに決める必要があるので、時間がかかりそう。
GCSに配置し、BigQuery Data Transfer Serviceで移送 実装少なそう。 バリデーションがかけられない。
GASでスプレッドシートから移送 自由にバリデーションをかけられる。 開発必要。
  1. 分析基盤の手前でバリデーションを実施したい
  2. デリバリーの制約があったため、できれば自TMで完結させてスピーディーに開発したい
    という観点から、GASでのデータロードを選択しました。

要件

では、本題のGAS。
やりたいことはシンプルに二つ。

  1. GASでスプレッドシートの中身に対してバリデーションをかける。
  2. バリデーションが通ったら、BigQueryにロードする。

データ仕様

スプレッドシート・ロード先のテーブルの中身は以下のように定義する。

カラム名 データ型 意味
id STRING 一意に定義するユニークなID
year STRING
month STRING
weekday STRING 曜日
num INT ユーザ数などの数字

バリデーション

バリデーションする要件は以下。

  1. idがユニークか
  2. yearが2022以上
  3. monthが1以上12以下
  4. weekdayが月曜〜日曜
  5. id~weekdayにNULL、空白がないか
  6. id~weekdayのカラムが存在するか

ロード

ロードする要件は以下。
project="{自分のプロジェクトID}"
dataset="{自分のデータセットID}"
table="target_table"

実装

validation.js
function validateData(sheet) {
  
  // 最終行を取得
  var lastRow = sheet.getLastRow(); 

  // バリデーションをかける範囲を取得
  var colId = sheet.getRange(2, getCol("id", sheet), lastRow-1).getValues()
  var colYear = sheet.getRange(2, getCol("year", sheet), lastRow-1).getValues()
  var colMonth = sheet.getRange(2, getCol("month", sheet), lastRow-1).getValues()
  var colWeekDay = sheet.getRange(2, getCol("weekday", sheet), lastRow-1).getValues()
  
  // バリデーションルールを設定
  var errorId = colId.flat().filter(function(value, i, self){
    return (self.indexOf(value) === i && i !== self.lastIndexOf(value)) || value == ""
  });
  var errorYear = colYear.filter(function(value){
    return Number(value)<2022
  });
  var errorMonth = colMonth.filter(function(value){
    const pattern = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
    return pattern.indexOf(String(value))==-1
  });
  var errorWeekDay = colWeekDay.filter(function(value){
    const pattern = ["月曜", "火曜", "水曜", "木曜", "金曜", "土曜", "日曜"];
    return pattern.indexOf(String(value))==-1
  });

  var errorLengthDict = {
    "id" : errorId.length,
    "year" : errorYear.length,
    "month" : errorMonth.length,
    "weekday" : errorWeekDay.length
  }
  
  // すべてのエラー数の合計値を計算
  const errorSum = Object.values(errorLengthDict).reduce((prev, current) => prev + current, 0);

  // エラーが一つもなければTrue
  if(errorSum==0){
    return true
  }else{
    var errorDict = {
    "id" : errorId,
    "year" : errorYear,
    "month" : errorMonth,
    "weekday" : errorWeekDay
  }
    throw new Error("validation rule error");
  }

}

// カラム名を指定して、該当カラム番号を返す。該当するカラムがIFになければエラーを投げる。
function getCol(string, sheet) {
  var lastCol = sheet.getLastColumn();  
  var range = sheet.getRange(1, 1, 1, lastCol)  
  var values = range.getValues()[0];  

  for (i=0; i<lastCol; i++) {
    if (values[i] == string) {
      return i + 1; // カラム番号を返す
    }
  }
  throw new Error('file does not have the column:'+string);
}

insert.js
function insertData(sheet, projectId, datasetId, tableId) {
  const jsonl = getData(sheet);

  const data = Utilities.newBlob(jsonl);
  const resultInsert = BigQuery.Jobs.insert({
    configuration: {
      load: {
        destinationTable: { projectId, datasetId, tableId },
        sourceFormat: 'NEWLINE_DELIMITED_JSON'
      }
    }
  }, projectId, data);
  return resultInsert;
}

//データ取得
function getData(sheet) {
  //行(横軸)と列(縦軸)の最大数を取得
  const maxRow = sheet.getLastRow();
  const maxColumn = sheet.getLastColumn();

  //key格納配列
  const keys = [];

  //データ格納配列
  const data = [];

  //1行目のkeyの名前取得
  for (var x = 1; x <= maxColumn; x++) {
    keys.push(sheet.getRange(1, x).getValue());
  }

  //データの取得
  const _values = sheet.getRange(2, 1, maxRow-1, maxColumn).getValues();
  
  for(var i = 0; i < _values.length; i++){
    const json = {};
    for (var k = 0; k < _values[i].length; k++) {
      json[keys[k]] = _values[i][k];
    }
    data.push(json);
  }

  //JSONLに変換
  return data.map(o => JSON.stringify(o)).join('\n');
}
main.js
function main() {
  // データ取得するシート情報を指定
  const spreadSheet = SpreadsheetApp.openByUrl("{スプレッドシートURL}");
  const sheet = spreadSheet.getSheetByName("{シート名}");

  // 更新先のテーブル情報を指定
  const projectId = "{自分のプロジェクトID}";
  const datasetId = "{自分のデータセットID}";
  const tableId = "target_table";
  
  try{
    // バリデーションを実行
    var resultValidation = validateData(sheet);

    // バリデーションをpassすればinsertを実行
    if(resultValidation){
      console.log("validation passed");
      resultInsert = insertData(sheet, projectId, datasetId, tableId);
    }
  }catch(e){
    console.error(e.message);
  }
}

注意点

GASを動かすアカウントはBigQueryの編集権限を持っている必要があります。
GASから出てくるログは微妙なので、GCPのロギングコンソールでデバッグした方がいいと思います。

参考

https://www.codeemall.info/posts/gas-load-into-bq/

Discussion