こんにちは!バクラク事業部 機械学習・データ部 データチームの@TrsNiumです。
私たちのデータ分析環境では、Google Sheetsを手軽な分析ツールとして活用しています。Google Sheetsでは、マーケットプレースのアドオンを利用して外部データを取得できます。ただし、マーケットプレースにはSnowflakeからデータを取得するアドオンが存在しないため、独自にアドオンを開発しました。本記事では、Google App Scripts(以降GASと略称)を使用したアドオンの開発方法と、Snowflakeからデータを取得する具体的な実装について解説します。
開発したアドオンの紹介
アドオンの目的
Google Sheetsは、手軽に利用でき、共有や簡単なデータ操作に非常に便利な表計算ツールです。BigQueryにはConnected Sheetsという公式の連携機能が提供されており、Google SheetsからBigQuery上のデータに簡単にアクセスできました。しかし、Snowflakeには、Google Sheetsと直接つなぐネイティブな連携機能が提供されていません。その結果、次のような課題が発生していました。
- SQLクエリの実行が手間: データアナリストがSnowflakeにログインしてクエリを実行し、結果をダウンロードしSheetsに取り込む必要があった。
- クエリのスケジュール実行ができない: 毎日決まった時間にデータを更新するような自動化ができず、Google Sheetsをダッシュボードなどの用途で利用できなかった。
本アドオンではこれらの課題を解決するために、以下の3つの主要機能を実現しました
- Snowflakeからのデータ取得を簡単化
- アドオン上のUIを通じて、SnowflakeのデータをGoogle Sheetsに直接取り込むことができます。
- クエリ管理機能
- ユーザーがSQLクエリを登録、編集、削除できる機能を提供。ワークフローに合わせて柔軟にクエリを管理することが可能です。
- クエリはGoogle Sheetsの一部として保存されるため、シートごとに異なるクエリを管理できます。
- データ更新の自動化(スケジュール機能)
- スケジュール実行を設定することで、毎日や数時間おきに指定したクエリを実行し、シートを自動更新します。
- これにより、定期的なデータ更新タスクが自動化され、手作業の負担を軽減します。
アドオンの全体構成
アドオンはGASを使用して開発されており、以下のフローで動作します。
- ユーザー操作
- Google Sheets上でアドオンメニューを開き、クエリの実行や設定を行います。
- Snowflakeとの連携
- OAuth2を利用してSnowflakeに安全に接続します。
- SQLクエリをSnowflakeに送信し、APIを通じて結果を取得します。
- データの反映
- Snowflakeから取得したデータをGoogle Sheetsに書き込みます。新しいタブとしてデータを追加したり、既存のデータを上書きすることが可能です。
バクラク事業部でのGoogle Sheetsの活用について
Google Sheetsは、手軽さと共有のしやすさから、さまざまな業務で活用されています。バクラク事業部では、Google Sheetsを活用し以下のように業務上で利用されています。
実験的な試作のダッシュボードとしての役割
Google Sheetsは、簡単に設定できるダッシュボードとして機能しています。特に、予算と実績を比較する予実管理や、KPIのトラッキングといった用途で活用されています。
特徴と活用
- リアルタイムなデータ反映: Snowflakeから取得した最新データを直接Google Sheetsに反映することで、常に更新された状態を維持します。
- スケジュール更新の活用: アドオンのスケジュール機能により、特定のタイミングでデータを自動的に更新し、手動更新の手間を省きます。
簡易的な分析環境
Google Sheetsは、SQLの知識がないメンバーでも簡単にデータ分析が行える環境を提供します。特に、ビジネスチームなどが日々の業務で活用しています。
特徴と活用
- 事前に設定されたクエリの利用: SQLに慣れていないメンバーでも、登録済みのクエリを選択するだけで必要なデータを取得可能。
- Google Sheetsの標準機能を活用した分析: 取得したデータをフィルタリングしたり、ピボットテーブルで集計するなど、Google Sheetsの基本機能を用いて柔軟に分析が可能。
- 迅速なフィードバック: アドオンを利用することで、必要なデータをすぐに取得し、仮説検証を素早く実行できます。
環境と準備
Snowflakeの認証方法について
我々のSnowflake環境では、ユーザー認証はSAML認証とOAuth認証のみになっています。パスワード認証は、攻撃リスクや漏洩リスクを伴うため、すべてのユーザーに対して無効化しています。詳細については、以前のエンジニアブログの記事をご参照ください。
Google Sheetsとの連携でOAuthを利用する理由
GASでは、OAuth2認証をサポートするライブラリが提供されており、このライブラリを活用することでセキュアなデータアクセスを実現します。
なぜOAuthを利用するのか?
- セキュリティの向上: トークンベースの認証により、アクセス権限の範囲と有効期限を細かく制御できます。アクセストークンは一時的な認証情報として機能し、権限の過剰付与を防ぎます。また、トークンが漏洩した場合でも、有効期限による自動失効や手動での失効により、影響を最小限に抑えることができます。
- トークンの再利用性: OAuthはアクセストークンとリフレッシュトークンを組み合わせて利用するため、認証情報を頻繁に再入力する必要がありません。たとえば、キーペア認証を使った場合、秘密鍵や公開鍵の管理・更新に手間がかかりますが、OAuthはリフレッシュトークンにより自動的に新しいトークンを取得できます。これにより、Snowflake APIへの長期的で安定した接続が可能になります。
Snowflake側の設定: Security Integrationの作成
SnowflakeでOAuth認証を利用するには、Security Integrationを作成する必要があります。これにより、SnowflakeアカウントがGoogle SheetsアドオンからのOAuth認証リクエストを受け付け、セキュアな接続を確立します。
以下のSQLクエリを CREATE INTEGRATION
権限を持ったロースで実行してください。デフォルトではACCOUNTADMINのみが保有しています。このクエリは、Google Sheetsアドオン用のSecurity Integrationを作成します。
CREATE OR REPLACE SECURITY INTEGRATION GOOGLE_SHEETS_ADDON TYPE = OAUTH OAUTH_CLIENT = CUSTOM OAUTH_CLIENT_TYPE = 'CONFIDENTIAL' -- Google App ScriptのリダイレクトURI。<YOUR APP SCRIPT ID>をアプリ固有のIDに置き換えてください。 OAUTH_REDIRECT_URI = 'https://script.google.com/macros/d/<YOUR APP SCRIPT ID>/usercallback' ENABLED = TRUE OAUTH_ALLOW_NON_TLS_REDIRECT_URI = TRUE OAUTH_ENFORCE_PKCE = FALSE OAUTH_USE_SECONDARY_ROLES = NONE -- このOAuthを利用できるロールを指定。SAMPLEロールにのみ許可。 PRE_AUTHORIZED_ROLES_LIST = ('SAMPLE') -- このOAuthでアクセスできないロールを指定。ACCOUNTADMINロールをブロック。 BLOCKED_ROLES_LIST = ('ACCOUNTADMIN', 'USERADMIN', 'SYSADMIN', 'SECURITYADMIN') -- リフレッシュトークンを発行(長期間のアクセスをサポート) OAUTH_ISSUE_REFRESH_TOKENS = TRUE -- リフレッシュトークンの有効期間を90日(秒単位)に設定 OAUTH_REFRESH_TOKEN_VALIDITY = 7776000 COMMENT = 'Used in Google Sheets Addon';
作成したSecurity IntegrationのOAuthに関するClient IDとClient Secretsは以下のように取得できます。
SELECT SYSTEM$SHOW_OAUTH_CLIENT_SECRETS('GOOGLE_SHEETS_ADDON');
GASにOAuth2ライブラリを追加する
GASでOAuth認証を利用するために、Googleが提供するOAuth2ライブラリを活用します。このライブラリを用いることで、Snowflakeへのセキュアなアクセスを実現します。
ライブラリの導入
GASのスクリプトエディタを開き、メニューから「ライブラリ」をクリックします。スクリプトIDの欄にOAuth2ライブラリのスクリプトID「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
」を入力し、「追加」を選択します。これによりOAuth2のライブラリを利用可能になります。利用方法については次のセクションで紹介します。
実装の詳細解説
このセクションでは、Google Sheetsアドオンの主な機能とその実装方法について解説します。アドオンの基本構成から、Snowflakeとのデータ連携、クエリの管理、スケジュール機能の実装までを順を追って説明します。
コードの量が多いため、興味がある方は展開して読んでみてください。
var USER_AGENT = "GoogleSheetsAppsScript/1.0"; var ROLE = "SAMPLE"; function onOpen(e) { var ui = SpreadsheetApp.getUi(); ui.createAddonMenu() .addItem('アドオンを起動', 'onHomepage') .addToUi(); } function onInstall(e) { onOpen(e); } function onHomepage(e) { var card = createHomePage(e); return card; } function createHomePage(e) { var card = CardService.newCardBuilder(); card.setHeader(CardService.newCardHeader().setTitle('Snowflake クエリ設定')); var noticeText = '<b>※ UIが更新されない場合は、右上のハンバーガーメニューから更新をお試しください。</b>'; var noticeParagraph = CardService.newTextParagraph().setText(noticeText); var section = CardService.newCardSection(); section.addWidget(noticeParagraph); // スプレッドシートが保存されていない場合は警告を表示 try{ PropertiesService.getDocumentProperties(); } catch(e) { var warningText = 'スプレッドシートが保存されていないため、クエリを取得できませんでした。スプレッドシートを保存してください。'; var warningParagraph = CardService.newTextParagraph().setText(warningText); section.addWidget(warningParagraph); card.addSection(section); return card.build(); } var queries = getQueries(); // クエリのリストを表示 for (var queryName in queries) { if (queries.hasOwnProperty(queryName)) { var queryInfo = queries[queryName]; var query = queryInfo.query || ''; var scheduled = queryInfo.scheduled; var keyValue = CardService.newKeyValue() .setTopLabel(queryName) .setContent(query) .setBottomLabel(scheduled ? 'スケジュール済み' : '未スケジュール'); var executeAction = CardService.newAction().setFunctionName('executeQueryAction').setParameters({queryName: queryName}); var executeButton = CardService.newTextButton() .setText('実行') .setOnClickAction(executeAction); var scheduleAction = CardService.newAction().setFunctionName('scheduleQueryAction').setParameters({queryName: queryName}); var scheduleButton = CardService.newTextButton() .setText(scheduled ? 'スケジュール解除' : 'スケジュール設定') .setOnClickAction(scheduleAction); var editAction = CardService.newAction().setFunctionName('editQueryAction').setParameters({queryName: queryName}); var editButton = CardService.newTextButton() .setText('編集') .setOnClickAction(editAction); var deleteAction = CardService.newAction().setFunctionName('deleteQueryAction').setParameters({queryName: queryName}); var deleteButton = CardService.newTextButton() .setText('削除') .setTextButtonStyle(CardService.TextButtonStyle.FILLED) .setOnClickAction(deleteAction); var buttonSet = CardService.newButtonSet() .addButton(executeButton) .addButton(scheduleButton) .addButton(editButton) .addButton(deleteButton); section.addWidget(keyValue); section.addWidget(buttonSet); } } let divider = CardService.newDivider(); section.addWidget(divider); // 新しいクエリを追加するボタン var addAction = CardService.newAction().setFunctionName('addQueryAction'); var addButton = CardService.newTextButton() .setText('新しいクエリを追加') .setOnClickAction(addAction) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); // 認証ボタン var authAction = CardService.newAction().setFunctionName('authenticateAction'); var authButton = CardService.newTextButton() .setText('認証') .setOnClickAction(authAction) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); section.addWidget(addButton); section.addWidget(authButton); card.addSection(section); return card.build(); } // 認証アクション function authenticateAction(e) { authenticate(); var notification = CardService.newNotification().setText('認証プロセスが開始されました。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } // 認証処理 function authenticate() { var service = getOAuthService(); if (!service.hasAccess()) { var authorizationUrl = service.getAuthorizationUrl(); var html = HtmlService.createHtmlOutput('<a href="' + authorizationUrl + '" target="_blank">ここをクリックして認証してください。</a> <br><br>※ 認証が完了したら、このタブを閉じて更新ボタンを押してください。') .setWidth(350) .setHeight(250); SpreadsheetApp.getUi().showModalDialog(html, '認証'); } else { SpreadsheetApp.getUi().alert('既に認証されています。'); } } // OAuth2サービスの取得 function getOAuthService() { var scriptProperty = PropertiesService.getScriptProperties(); var SNOWFLAKE_CLIENT_ID = scriptProperty.getProperty("SNOWFLAKE_CLIENT_ID"); var SNOWFLAKE_CLIENT_SECRET = scriptProperty.getProperty("SNOWFLAKE_CLIENT_SECRET"); var SNOWFLAKE_BASE_URL = scriptProperty.getProperty("SNOWFLAKE_BASE_URL"); var SNOWFLAKE_AUTH_URL = `${SNOWFLAKE_BASE_URL}/oauth/authorize`; var SNOWFLAKE_TOKEN_URL = `${SNOWFLAKE_BASE_URL}/oauth/token-request`; return OAuth2.createService('Snowflake') .setAuthorizationBaseUrl(SNOWFLAKE_AUTH_URL) .setTokenUrl(SNOWFLAKE_TOKEN_URL) .setClientId(SNOWFLAKE_CLIENT_ID) .setClientSecret(SNOWFLAKE_CLIENT_SECRET) .setCallbackFunction('authCallback') // コールバック関数を設定 .setPropertyStore(PropertiesService.getUserProperties()) .setScope(`refresh_token session:role:${ROLE}`) .setParam('response_type', 'code'); } // クエリの追加アクション function addQueryAction(e) { var card = CardService.newCardBuilder(); card.setHeader(CardService.newCardHeader().setTitle('新しいクエリを追加')); var queryNameInput = CardService.newTextInput() .setFieldName('queryName') .setTitle('クエリ名'); var queryInput = CardService.newTextInput() .setFieldName('query') .setTitle('SQLクエリ') .setMultiline(true); var warehouseSelection = CardService.newSelectionInput() .setType(CardService.SelectionInputType.DROPDOWN) .setTitle('Warehouseを選択') .setFieldName('warehouse') .addItem('SAMPLE', 'PLAYGROUND', true) .addItem('SAMPLE_S', 'SAMPLE_S', false) .addItem('SAMPLE_M', 'SAMPLE_M', false) .addItem('SAMPLE_L', 'SAMPLE_L', false) .addItem('SAMPLE_XL', 'SAMPLE_XL', false); var saveAction = CardService.newAction().setFunctionName('saveNewQueryAction'); var saveButton = CardService.newTextButton() .setText('保存') .setOnClickAction(saveAction) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); var section = CardService.newCardSection() .addWidget(queryNameInput) .addWidget(queryInput) .addWidget(warehouseSelection) .addWidget(saveButton); card.addSection(section); var navigation = CardService.newNavigation().pushCard(card.build()); return CardService.newActionResponseBuilder() .setNavigation(navigation) .build(); } // 新しいクエリの保存 function saveNewQueryAction(e) { var queryName = e.commonEventObject.formInputs.queryName.stringInputs.value[0]; var query = e.commonEventObject.formInputs.query.stringInputs.value[0]; var warehouse = e.commonEventObject.formInputs.warehouse.stringInputs.value[0] || 'PLAYGROUND'; saveQuery(queryName, query, warehouse); var navigation = CardService.newNavigation() .popToRoot() .updateCard(createHomePage(e)); var notification = CardService.newNotification().setText('クエリが保存されました。'); return CardService.newActionResponseBuilder() .setNavigation(navigation) .setNotification(notification) .build(); } // クエリの編集アクション function editQueryAction(e) { var queryName = e.parameters.queryName; var queries = getQueries(); var queryInfo = queries[queryName]; if (!queryInfo) { var notification = CardService.newNotification().setText('クエリが見つかりませんでした。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } var query = queryInfo.query || ''; var warehouse = queryInfo.warehouse || 'SAMPLE'; var card = CardService.newCardBuilder(); card.setHeader(CardService.newCardHeader().setTitle('クエリを編集: ' + queryName)); var queryInput = CardService.newTextInput() .setFieldName('query') .setTitle('SQLクエリ') .setMultiline(true) .setValue(query); var warehouseSelection = CardService.newSelectionInput() .setType(CardService.SelectionInputType.DROPDOWN) .setTitle('Warehouseを選択') .setFieldName('warehouse') .addItem('SAMPLE', 'SAMPLE', warehouse === 'SAMPLE') .addItem('SAMPLE_S', 'SAMPLE_S', warehouse === 'SAMPLE_S') .addItem('SAMPLE_M', 'SAMPLE_M', warehouse === 'SAMPLE_M') .addItem('SAMPLE_L', 'SAMPLE_L', warehouse === 'SAMPLE_L') .addItem('SAMPLE_XL', 'SAMPLE_XL', warehouse === 'SAMPLE_XL'); var saveAction = CardService.newAction().setFunctionName('saveEditedQueryAction').setParameters({queryName: queryName}); var saveButton = CardService.newTextButton() .setText('保存') .setOnClickAction(saveAction) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); var section = CardService.newCardSection() .addWidget(queryInput) .addWidget(warehouseSelection) .addWidget(saveButton); card.addSection(section); var navigation = CardService.newNavigation().pushCard(card.build()); return CardService.newActionResponseBuilder() .setNavigation(navigation) .build(); } // 編集したクエリの保存 function saveEditedQueryAction(e) { var queryName = e.parameters.queryName; var query = e.commonEventObject.formInputs.query.stringInputs.value[0]; var warehouse = e.commonEventObject.formInputs.warehouse.stringInputs.value[0] || 'PLAYGROUND'; saveQuery(queryName, query, warehouse); var navigation = CardService.newNavigation() .popToRoot() .updateCard(createHomePage(e)); var notification = CardService.newNotification().setText('クエリが更新されました。'); return CardService.newActionResponseBuilder() .setNavigation(navigation) .setNotification(notification) .build(); } function deleteQueryAction(e) { var queryName = e.parameters.queryName; var queries = getQueries(); if (queries[queryName]) { delete queries[queryName]; saveQueries(queries); } var notification = CardService.newNotification().setText('クエリが削除されました。'); var navigation = CardService.newNavigation().popToRoot().updateCard(createHomePage(e)); return CardService.newActionResponseBuilder() .setNavigation(navigation) .setNotification(notification) .build(); } // クエリの実行アクション function executeQueryAction(e) { var queryName = e.parameters.queryName; var queries = getQueries(); var queryInfo = queries[queryName]; if (!queryInfo) { var notification = CardService.newNotification().setText('クエリが見つかりませんでした。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } var activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var spreadsheetId = activeSpreadsheet.getId(); var sheet = activeSpreadsheet.getSheetByName(queryName); if (!sheet) { sheet = activeSpreadsheet.insertSheet(queryName); } var sheetId = sheet.getSheetId(); var queryTag = `spreadsheet-query-${spreadsheetId}-${sheetId}`; var result = executeQuery(queryInfo, queryTag); if (result) { writeDataToSheet(result, queryName); var notification = CardService.newNotification().setText('クエリが実行されました。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } else { var notification = CardService.newNotification().setText('クエリの実行に失敗しました。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } } // クエリのスケジュール設定・解除アクション function scheduleQueryAction(e) { var queryName = e.parameters.queryName; var queries = getQueries(); var queryInfo = queries[queryName]; if (!queryInfo) { var notification = CardService.newNotification().setText('クエリが見つかりませんでした。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } var scheduled = queryInfo.scheduled; if (scheduled) { cancelScheduledQuery(queryName); var notification = CardService.newNotification().setText('クエリのスケジュールを解除しました。'); var navigation = CardService.newNavigation().popToRoot().updateCard(createHomePage(e)); return CardService.newActionResponseBuilder() .setNotification(notification) .setNavigation(navigation) .build(); } else { var formInputs = e.commonEventObject.formInputs || {}; var schedulingMethod = 'interval'; if (formInputs.schedulingMethod && formInputs.schedulingMethod.stringInputs.value[0]) { schedulingMethod = formInputs.schedulingMethod.stringInputs.value[0]; } var intervalValue = ''; if (formInputs.interval && formInputs.interval.stringInputs.value[0]) { intervalValue = formInputs.interval.stringInputs.value[0]; } var timeValue = ''; if (formInputs.time && formInputs.time.stringInputs.value[0]) { timeValue = formInputs.time.stringInputs.value[0]; } var card = CardService.newCardBuilder(); card.setHeader(CardService.newCardHeader().setTitle('スケジュール設定: ' + queryName)); var schedulingMethodInput = CardService.newSelectionInput() .setType(CardService.SelectionInputType.RADIO_BUTTON) .setTitle('スケジュール方法を選択') .setFieldName('schedulingMethod') .addItem('更新間隔で設定 (毎 N 時間)', 'interval', schedulingMethod === 'interval') .addItem('特定の時刻に設定 (毎日)', 'time', schedulingMethod === 'time') .setOnChangeAction( CardService.newAction() .setFunctionName('scheduleQueryAction') .setParameters({ queryName: queryName }) ); var intervalInput = CardService.newTextInput() .setFieldName('interval') .setTitle('更新間隔 (時間)') .setHint('1以上の整数を入力') .setValue(intervalValue); var timeInput = CardService.newTextInput() .setFieldName('time') .setTitle('実行時刻 (HH:MM)') .setHint('24時間形式 (例: 06:00)') .setValue(timeValue); // セクションにウィジェットを追加 var section = CardService.newCardSection() .addWidget(schedulingMethodInput); if (schedulingMethod === 'interval') { section.addWidget(intervalInput); } else if (schedulingMethod === 'time') { section.addWidget(timeInput); } var saveAction = CardService.newAction() .setFunctionName('saveScheduleAction') .setParameters({ queryName: queryName }); var saveButton = CardService.newTextButton() .setText('スケジュール設定') .setOnClickAction(saveAction) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); section.addWidget(saveButton); card.addSection(section); var navigation = CardService.newNavigation().updateCard(card.build()); return CardService.newActionResponseBuilder() .setNavigation(navigation) .build(); } } function saveScheduleAction(e) { var queryName = e.parameters.queryName; var formInputs = e.commonEventObject.formInputs; var schedulingMethod = formInputs.schedulingMethod.stringInputs.value[0]; var queries = getQueries(); if (!queries[queryName]) { var notification = CardService.newNotification().setText('クエリが見つかりませんでした。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } cancelScheduledQuery(queryName); var trigger; if (schedulingMethod === 'interval') { var interval = parseInt(formInputs.interval.stringInputs.value[0], 10); if (isNaN(interval) || interval < 1) { var notification = CardService.newNotification().setText('有効な更新間隔を入力してください。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } trigger = ScriptApp.newTrigger('scheduledFetchData') .timeBased() .everyHours(interval) .create(); // クエリ情報を更新 queries[queryName].scheduled = true; queries[queryName].scheduleType = 'interval'; queries[queryName].interval = interval; queries[queryName].triggerId = trigger.getUniqueId(); saveQueries(queries); var notification = CardService.newNotification().setText('クエリが' + interval + '時間ごとにスケジュールされました。'); } else if (schedulingMethod === 'time') { var timeStr = formInputs.time.stringInputs.value[0]; var timeParts = timeStr.split(':'); if (timeParts.length !== 2) { var notification = CardService.newNotification().setText('有効な時刻を入力してください (例: 06:00)。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } var hour = parseInt(timeParts[0], 10); var minute = parseInt(timeParts[1], 10); if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { var notification = CardService.newNotification().setText('有効な時刻を入力してください (例: 06:00)。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } trigger = ScriptApp.newTrigger('scheduledFetchData') .timeBased() .atHour(hour) .nearMinute(minute) .everyDays(1) .create(); queries[queryName].scheduled = true; queries[queryName].scheduleType = 'time'; queries[queryName].hour = hour; queries[queryName].minute = minute; queries[queryName].triggerId = trigger.getUniqueId(); saveQueries(queries); var notification = CardService.newNotification().setText('クエリが毎日 ' + ('0'+hour).slice(-2) + ':' + ('0'+minute).slice(-2) + ' にスケジュールされました。'); } else { var notification = CardService.newNotification().setText('スケジュール方法を選択してください。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); } var navigation = CardService.newNavigation().popToRoot().updateCard(createHomePage(e)); return CardService.newActionResponseBuilder() .setNavigation(navigation) .setNotification(notification) .build(); } // クエリのスケジュールを解除 function cancelScheduledQuery(queryName) { var queries = getQueries(); if (!queries[queryName] || !queries[queryName].triggerId) { return; } var triggerIdToDelete = queries[queryName].triggerId; var triggers = ScriptApp.getProjectTriggers(); triggers.forEach(function(trigger) { if (trigger.getUniqueId() === triggerIdToDelete) { ScriptApp.deleteTrigger(trigger); } }); // クエリ情報を更新 queries[queryName].scheduled = false; delete queries[queryName].triggerId; saveQueries(queries); } // スケジュールされたトリガーから呼び出される関数 function scheduledFetchData() { var queries = getQueries(); for (var queryName in queries) { var queryInfo = queries[queryName]; if (queryInfo.scheduled) { var activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var spreadsheetId = activeSpreadsheet.getId(); var sheet = activeSpreadsheet.getSheetByName(queryName); if (!sheet) { sheet = activeSpreadsheet.insertSheet(queryName); } var sheetId = sheet.getSheetId(); var queryTag = `spreadsheet-query-${spreadsheetId}-${sheetId}`; var result = executeQuery(queryInfo, queryTag); if (result) { writeDataToSheet(result, queryName); } } } } // クエリを保存・取得する関数(シートIDを使用しない) function saveQuery(queryName, query, warehouse) { var documentProperties = PropertiesService.getDocumentProperties(); var queries = JSON.parse(documentProperties.getProperty('queries') || '{}'); queries[queryName] = { query: query, warehouse: warehouse, scheduled: queries[queryName] ? queries[queryName].scheduled : false, triggerId: queries[queryName] ? queries[queryName].triggerId : null }; documentProperties.setProperty('queries', JSON.stringify(queries)); } function saveQueries(queries) { var documentProperties = PropertiesService.getDocumentProperties(); documentProperties.setProperty('queries', JSON.stringify(queries)); } function getQueries() { var documentProperties = PropertiesService.getDocumentProperties(); var queries = JSON.parse(documentProperties.getProperty('queries') || '{}'); return queries; } function getAllQueries() { var documentProperties = PropertiesService.getDocumentProperties(); var queries = JSON.parse(documentProperties.getProperty('queries') || '{}'); return queries; } // Snowflakeとのデータ取得関連の関数 function executeQuery(queryInfo, queryTag) { var service = getOAuthService(); if (!service.hasAccess()) { SpreadsheetApp.getUi().alert('最初に認証を行ってください。'); return null; } try { // ステートメントをリクエストして、ステートメントハンドルとスキーマ情報を取得 var statementResult = requestStatement(queryInfo, queryTag); var statementHandle = statementResult.statementHandle; var rowType = statementResult.rowType; var partitions = statementResult.partitions; // ステートメントハンドルとパーティション情報を使用してデータを取得 var data = getPartitionedStatement(statementHandle, partitions); return { data: data, rowType: rowType }; } catch (e) { console.error('executeQuery error: ' + e); SpreadsheetApp.getUi().alert('クエリの実行中にエラーが発生しました: ' + e.message); return null; } } function requestStatement(queryInfo, queryTag) { var service = getOAuthService(); var token = service.getAccessToken(); var warehouse = queryInfo.warehouse || 'PLAYGROUND'; var requestBody = { statement: queryInfo.query, role: ROLE, warehouse: warehouse, parameters: { DATE_OUTPUT_FORMAT: "YYYY/MM/DD", TIMESTAMP_OUTPUT_FORMAT: "YYYY/MM/DD HH24:MI:SS", CLIENT_RESULT_CHUNK_SIZE: 50, // 50MB USE_CACHED_RESULT: true, QUERY_TAG: queryTag } }; var requestOptions = { method: 'post', contentType: 'application/json', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT, "X-Snowflake-Authorization-Token-Type": "OAUTH" }, payload: JSON.stringify(requestBody), muteHttpExceptions: true }; var SNOWFLAKE_BASE_URL = PropertiesService.getScriptProperties().getProperty("SNOWFLAKE_BASE_URL"); var queryRequestResponse = UrlFetchApp.fetch(`${SNOWFLAKE_BASE_URL}/api/v2/statements`, requestOptions); var responseCode = queryRequestResponse.getResponseCode(); var responseBody = queryRequestResponse.getContentText(); var queryRequestResult = JSON.parse(responseBody); if (responseCode === 200) { var statementHandle = queryRequestResult.statementHandle; var rowType = queryRequestResult.resultSetMetaData.rowType; var partitions = queryRequestResult.resultSetMetaData.partitionInfo; return { statementHandle: statementHandle, rowType: rowType, partitions: partitions }; } else if (responseCode === 202) { var statementHandle = queryRequestResult.statementHandle; var statementStatusUrl = queryRequestResult.statementStatusUrl; while (true) { Utilities.sleep(3000); var checkOptions = { method: 'get', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': USER_AGENT, "X-Snowflake-Authorization-Token-Type": "OAUTH" }, muteHttpExceptions: true }; var checkResponse = UrlFetchApp.fetch(`${SNOWFLAKE_BASE_URL}${statementStatusUrl}`, checkOptions); var checkResponseCode = checkResponse.getResponseCode(); var checkResponseBody = checkResponse.getContentText(); var checkResult = JSON.parse(checkResponseBody); if (checkResponseCode === 200) { var rowType = checkResult.resultSetMetaData.rowType; var partitions = checkResult.resultSetMetaData.partitionInfo; return { statementHandle: statementHandle, rowType: rowType, partitions: partitions }; } else if (checkResponseCode === 202) { continue; } else { throw new Error('failed to check query status: ' + checkResponseBody); } } } else { throw new Error('failed to request query: ' + responseBody); } } function getPartitionedStatement(statementHandle, partitions) { var service = getOAuthService(); var token = service.getAccessToken(); var data = []; var SNOWFLAKE_BASE_URL = PropertiesService.getScriptProperties().getProperty("SNOWFLAKE_BASE_URL"); var requests = partitions.map(function (partition, index) { return { url: `${SNOWFLAKE_BASE_URL}/api/v2/statements/${statementHandle}?partition=${index}`, method: "get", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", "User-Agent": USER_AGENT, "X-Snowflake-Authorization-Token-Type": "OAUTH", }, muteHttpExceptions: true, }; }); var responses = UrlFetchApp.fetchAll(requests); console.log("partition size: " + responses.length) responses.forEach(function (response, index) { var queryResultText = response.getContentText(); if (response.getResponseCode() !== 200) { console.error("Failed to fetch partition data", response.getResponseCode(), queryResultText); return; } try { queryResult = JSON.parse(queryResultText); data = data.concat(queryResult.data); } catch (e) { console.error("Failed to parse JSON for partition", index, ":", e.message); throw e; } }); return data; } function writeDataToSheet(result, queryName) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName(queryName); if (!sheet) { sheet = ss.inserftSheet(queryName); } else { sheet.clearContents(); } sheet.setTabColor('#4285F4'); var rowType = result.rowType; var data = result.data; // カラム名を取得 var columns = rowType.map(function(column) { return column.name; }); var values = []; values.push(columns); data.forEach(function(row) { var rowData = row.map(function(value) { return value; }); values.push(rowData); }); var numRows = values.length; var numColumns = columns.length; sheet.getRange(1, 1, numRows, numColumns).setValues(values); var syncTime = new Date(); sheet.getRange(1, numColumns + 2).setValue('同期時刻'); sheet.getRange(1, numColumns + 3).setValue(syncTime.toLocaleString()); }
アドオンの操作画面について
アドオンの操作画面はGASで提供されているCard Serviceを用いて構築しています。Card Serviceは、Google WorkspaceのUI上で簡単にフォームやボタン、テキストなどの要素を配置できる仕組みです。
UIの構築の基本
Card Serviceでは、以下の要素を用いて操作画面を構築します。
- カード:画面の最上位要素。画面全体を表す枠です。
- セクション:カードの中に配置されるグループ要素。関連するウィジェットをまとめる単位です。
- ウィジェット:ボタンやテキストなど、ユーザーとやり取りする個別のUI要素です。 以下は、シンプルな操作画面の構築例です。
function createHomePage() { var card = CardService.newCardBuilder(); card.setHeader(CardService.newCardHeader().setTitle('Snowflake クエリ設定')); var section = CardService.newCardSection(); // 注意テキスト var noticeText = '<b>※ UIが更新されない場合は、右上のハンバーガーメニューから更新をお試しください。</b>'; section.addWidget(CardService.newTextParagraph().setText(noticeText)); // 認証ボタン var authButton = CardService.newTextButton() .setText('認証') .setOnClickAction(CardService.newAction().setFunctionName('authenticateAction')) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); // クエリ追加ボタン var addButton = CardService.newTextButton() .setText('新しいクエリを追加') .setOnClickAction(CardService.newAction().setFunctionName('addQueryAction')) .setTextButtonStyle(CardService.TextButtonStyle.FILLED); section.addWidget(authButton); section.addWidget(addButton); card.addSection(section); return card.build(); }
コード解説
- CardService.newCardBuilder()
- 新しいカード(画面)を作成する関数です。カードにはヘッダーや複数のセクションを追加できます。
- セクションとウィジェット
- セクション:CardService.newCardSection()を使って、要素を整理して表示します。
- テキスト:CardService.newTextParagraph()で文字列や注意文を表示します。
- ボタン:CardService.newTextButton()を使い、アクション(関数)を定義します。
- ボタン押下時のアクション
- ボタンを押すと、setOnClickAction()で指定された関数が実行されます。
- 例えば、以下のコードでは「認証」ボタンを押すとauthenticateAction()関数が実行されます。
var authButton = CardService.newTextButton() .setText('認証') .setOnClickAction(CardService.newAction().setFunctionName('authenticateAction'));
便利なツール
Card ServiceでUIを構築する際に、コードだけで画面レイアウトを作成するのは手間がかかります。そのため、Card Builderを使用すると、ドラッグ&ドロップで画面を構築できるため非常に便利です。
Snowflakeとの認証
Google SheetsアドオンからSnowflakeのデータを取得するには、SnowflakeのAPIを利用する必要があります。このため、前述のとおりOAuth認証を使用して、セキュアな接続を確保しています。この認証には、Googleが提供するOAuth2ライブラリを活用します。
OAuth認証フローの仕組み
OAuth認証は、SnowflakeとGoogle Sheetsアドオンの間で次のような手順で行われます
- 認証リクエストの発行: アドオンからSnowflakeのOAuth認証エンドポイントにアクセスし、ユーザーに認証画面を表示します。
- アクセストークンの取得: ユーザーが認証を許可すると、Snowflakeからアクセストークンが発行されます。このトークンを使用してAPIリクエストを行います。
- リフレッシュトークンの利用: アクセストークンが期限切れになった場合、リフレッシュトークンを使って新しいトークンを取得します。
- トークン管理: 取得したトークンはGoogle Apps ScriptのPropertiesServiceを用いて安全に保存・管理します。
以下のコードでは、各ステップを順番に実装しています。
1. OAuthサービスの定義
OAuth2サービスの設定を行います。Snowflakeの認証エンドポイント、クライアントID、クライアントシークレット、スコープなどを定義します。
function getOAuthService() { return OAuth2.createService('Snowflake') .setAuthorizationBaseUrl('https://<replace_your_snowflake_account_name>.snowflakecomputing.com/oauth/authorize') .setTokenUrl('https://<replace_your_snowflake_account_name>.snowflakecomputing.com/oauth/token-request') .setClientId('YOUR_SNOWFLAKE_CLIENT_ID') // Security Integrationに設定したClient ID .setClientSecret('YOUR_SNOWFLAKE_CLIENT_SECRET') // Security Integrationで設定したClient Secret .setCallbackFunction('authCallback') // 認証後に呼び出されるコールバック関数 .setPropertyStore(PropertiesService.getUserProperties()) // トークンの保存先(ユーザーごとに管理) .setScope('refresh_token session:role:SAMPLE') // Snowflakeの特定ロールでアクセス(例としてSAMPLEロールを使用) .setParam('response_type', 'code'); }
2. 認証後のコールバック処理
ユーザーがSnowflakeで認証を許可した後、認証コードを処理し、アクセストークンとリフレッシュトークンを取得します。
function authCallback(request) { var service = getOAuthService(); var authorized = service.handleCallback(request); if (authorized) { return HtmlService.createHtmlOutput('認証に成功しました。このタブを閉じてください。'); } else { return HtmlService.createHtmlOutput('認証が拒否されました。このタブを閉じてください。'); } }
3. 認証ボタンの実装
Google SheetsアドオンのUI上に「認証」ボタンを表示し、ユーザーが認証を開始できるようにします。
// 「認証」ボタンを押したさいに実行される function authenticateAction(e) { authenticate(); var notification = CardService.newNotification().setText('認証プロセスが開始されました。'); return CardService.newActionResponseBuilder() .setNotification(notification) .build(); }
4. 認証処理の本体
認証を行うメイン関数です。アクセストークンが既に存在する場合は何もしませんが、トークンが無効な場合はSnowflake認証画面のURLを生成し、ユーザーに表示します。
function authenticate() { var service = getOAuthService(); if (!service.hasAccess()) { var authorizationUrl = service.getAuthorizationUrl(); var html = HtmlService.createHtmlOutput( '<a href="' + authorizationUrl + '" target="_blank">ここをクリックして認証してください。</a>' ).setWidth(350).setHeight(250); SpreadsheetApp.getUi().showModalDialog(html, '認証'); } else { SpreadsheetApp.getUi().alert('既に認証されています。'); } }
この認証フローにより、Google SheetsアドオンはSnowflakeとセキュアに接続でき、アクセストークンやリフレッシュトークンの管理を自動化できます。また、アクセストークンは次のように取得できます。
var service = getOAuthService(); var token = service.getAccessToken();
Snowflakeにクエリを投げてクエリ結果を取得する
Google SheetsアドオンからSnowflakeにクエリを実行し、取得した結果をシートに反映する流れについて解説します。クエリ実行処理は以下のステップで構成されます。
クエリ実行のステップ概要
以下のフローを基に、クエリ結果を取得する流れを説明します。
クエリ実行のコード
以下は、SnowflakeにSQLクエリを送信し、結果を取得してGoogle Sheetsに書き込む関数です。
SQLクエリをリクエスト
SnowflakeのSQL APIを使用し、クエリを送信します。/api/v2/statements
エンドポイントに対してクエリをPOSTし、ステートメントハンドルを取得します。
function requestStatement(queryInfo, queryTag) { var service = getOAuthService(); var token = service.getAccessToken(); // アクセストークンを取得する var warehouse = queryInfo.warehouse || 'SAMPLE'; var requestBody = { statement: queryInfo.query, // 実行するクエリを指定する role: ROLE, warehouse: warehouse, // クエリを実行するwarehouseを指定する parameters: { DATE_OUTPUT_FORMAT: "YYYY/MM/DD", // 日付のフォーマットを指定 TIMESTAMP_OUTPUT_FORMAT: "YYYY/MM/DD HH24:MI:SS", // タイムスタンプのフォーマットを指定 CLIENT_RESULT_CHUNK_SIZE: 50, // GASではUrlFetchApp.fetchの1callあたりのデータ量が50MBの制限があるため、チャンクサイズの最大を50MBに設定。 QUERY_TAG: queryTag // クエリヒストリーから検索しやすいようにクエリタグを設定する } }; var SNOWFLAKE_BASE_URL = PropertiesService.getScriptProperties().getProperty("SNOWFLAKE_BASE_URL"); var response = UrlFetchApp.fetch(`${SNOWFLAKE_BASE_URL}/api/v2/statements`, { method: 'post', contentType: 'application/json', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, payload: JSON.stringify(requestBody), muteHttpExceptions: true }); var result = JSON.parse(response.getContentText()); return { statementHandle: result.statementHandle, rowType: result.resultSetMetaData.rowType, partitions: result.resultSetMetaData.partitionInfo }; }
なお、このコード例では説明を省略していますが、クエリステートメントの実行時間が45秒を超えると、HTTPステータス202が返されます。この場合、クエリステートメントの成功または失敗が確定するまで待機処理を実装する必要がありますのでご注意ください。
クエリ結果の取得
クエリ結果はパーティション単位で分割されて返されます。すべてのパーティションを順に取得し、データを結合します。
function getPartitionedStatement(statementHandle, partitions) { var service = getOAuthService(); var token = service.getAccessToken(); var SNOWFLAKE_BASE_URL = PropertiesService.getScriptProperties().getProperty("SNOWFLAKE_BASE_URL"); var data = []; var requests = partitions.map((partition, index) => { return { url: `${SNOWFLAKE_BASE_URL}/api/v2/statements/${statementHandle}?partition=${index}`, method: 'get', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, muteHttpExceptions: true }; }); var responses = UrlFetchApp.fetchAll(requests); responses.forEach((response, index) => { var content = JSON.parse(response.getContentText()); if (response.getResponseCode() === 200) { data = data.concat(content.data); } else { console.error("Failed to fetch partition data for index " + index); } }); return data; }
Google Sheetsにデータを反映
取得したデータをGoogle Sheetsに書き込む際は、setValues
関数を使用して一括処理を行います。sheet.appendRow
という関数も利用可能ですが、各行の書き込みごとにAPIリクエストが発生してパフォーマンスが低下するため、データはまとめて書き込むようにしています。
function writeDataToSheet(result, queryName) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName(queryName); // シートが存在しない場合は新しく作成 if (!sheet) { sheet = ss.insertSheet(queryName); } else { sheet.clearContents(); // 既存データをクリア } var rowType = result.rowType; // カラム情報 var data = result.data; // クエリ結果のデータ // カラム名を取得 var columns = rowType.map(function(column) { return column.name; // カラムの名前を配列に追加 }); // データの準備 var values = []; values.push(columns); // 1行目にカラム名を追加 // クエリ結果のデータを整形 data.forEach(function(row) { var rowData = row.map(function(value) { return value; }); values.push(rowData); }); // データを書き込む範囲を取得 var numRows = values.length; // 行数(カラム名 + データ行) var numColumns = columns.length; // 列数 // 一括でデータを書き込む sheet.getRange(1, 1, numRows, numColumns).setValues(values); // 同期時刻の表示 var syncTime = new Date(); sheet.getRange(1, numColumns + 2).setValue('同期時刻'); // カラム名の隣に「同期時刻」 sheet.getRange(1, numColumns + 3).setValue(syncTime.toLocaleString()); // 実際の同期時刻を表示 }
UIとユーザー体験の工夫
アドオンの開発において、単に機能を実装するだけでなく、ユーザー体験も大事だと考えました。特にGoogle Sheetsアドオンでは、直感的で使いやすいUI設計と、利用者のフィードバックを活かした改善を行いました。
UIの工夫
GASで提供されるCard Serviceを用いて、次のようなUI設計の工夫を行いました。
- 直感的なボタン配置: 「実行」「スケジュール設定」「編集」「削除」などの操作ボタンを一つのセットとして表示し、クエリごとの管理が直感的に行えるようにしました
- フィードバックメッセージの表示: ユーザーの操作結果を即座に通知するため、通知バー(CardService.newNotification)やダイアログ(SpreadsheetApp.getUi())を利用しています。
- 状態の視覚化: クエリのスケジュール状態(例: スケジュール済み、未スケジュール)を明確に表示することで、ユーザーが現在の状態を一目で把握できるようにしました。
一部ユーザーへの公開とフィードバックループを通した改善
アドオンの初期リリースでは、全社展開する前に、一部のGoogle Sheetsユーザーに限定して公開しました。これは、早い段階でフィードバックを収集し、改善に繋げることが重要だと考えたからです。
リリース後、実際に使ってもらうことで、自分が想定していなかった要望や改善点が浮き彫りになりました。例えば、当初は「クエリのスケジューリング機能」に重点を置いていたのですが、一部のユーザーから「登録済みクエリを手軽に切り替えたい」という意見をいただき、クエリ管理画面のUIをより直感的なものに改良しました。
このプロセスを通じて、以下のような学びを得ました
- 想定外の使い方やニーズが分かる: ユーザーが実際にどのようにツールを利用するか、事前にすべてを把握するのは難しい。早期の限定公開により、設計段階では見えなかった課題を発見できました。
- 小規模に試す安心感: 全社展開前に、一部のユーザーに限定することで、不具合やUXの課題を段階的に解消でき、より完成度の高い状態で広くリリースできました。
こうしたフィードバックループを通じて、アドオンをさらにユーザーフレンドリーなものに仕上げることができました。この経験を通じて、「社内ツールでも、段階的なリリースとユーザーの声を活かすことの重要性」を改めて実感しました。
まとめ
本記事では、Google SheetsとSnowflakeを効果的に連携させ、データ分析業務の効率を大幅に向上させるアドオンの開発プロセスについて詳しく解説しました。現在、Snowflakeの公式アドオンが提供されていない状況において、私たちは誰もが簡単にデータを取得・分析できる環境の構築を目指し、独自のアドオン開発に取り組みました。この開発により、SQLの専門知識を持たないユーザーでも、使い慣れたGoogle Sheets上で直感的な操作によってクエリを実行し、必要なデータを円滑に取り込んで分析作業を行えるようになりました。さらに、定期的なデータ更新の自動化や、複雑なクエリの実行も可能となり、データ分析業務の効率化と品質向上を実現することができました。