Slackの中にカスタムデータを保存できる！Tables APIを使ってみる

本記事は [Slack Advent Calendar 2021](https://qiita.com/advent-calendar/2021/slack)  5日目の記事です。

# Slackのアプリ構築基盤が大幅アップデート
2021年11月16日にSlackのアプリ開発についてアップデートがありました。詳細は[ニュースリリース](https://slack.com/intl/ja-jp/blog/developers/faster-simpler-way-build-apps)にありますが、その中でも個人的に気になった「**Tables API**」を触ってみたいと思います。

:::note warn
【前提条件】
本記事の執筆時点で新しいアプリ構築基盤は開発者向けベータ版です。
利用するには開発者向けベータ版プラットフォーム（https://slack.com/intl/ja-jp/platform-beta）へ申し込む必要があります。
:::

# Tables APIとは
これまではSlackアプリでデータを保存が必要な場合は外部サービスを使って保存する必要がありました。
**今回リリースされたTables APIを使用することにより、アプリ独自のデータをSlackの中に保存することができるようになります！**
テーブルに対してCRUD操作ができるのはもちろん、利用規模についても`「運用の規模は問わず、状態を一時的に管理したい状況から、Slack で機能する完全なデータ分析システムを構築する場合まで対応が可能です。」`とかなり幅広い利用シーンに対応できます！

# もう少し技術的な話
### テーブルを操作するには
テーブルに対するデータ操作はSlack CLIの

```
slack create [project-name]
```
によって作成されるテンプレートの `Triggers` や `Workflows`、`Functions`　を通して操作する必要があります。(`Tables`はテーブルのスキーマ定義のみ)
それぞれのコンポーネントの役割や連携については下図のようになります。
![slacktable.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149212/b51f2240-81df-a620-e8bb-9c3ec99110e8.png)



`slack create`で生成されるプロジェクト構成

```
.slack/
  - apps.json
  - cli.json
assets/
  - icon.png
functions/
 - myfunction.ts
tables/
 - mytable.ts
triggers/
 - mytrigger.ts
workflows/
 - myworkflow.ts
import_map.json
project.ts
README.md
```

### サポートされている型
アプリは複数のテーブルを持つことができ、通常のデータストアと同じく型があります。文字、数値、真理値といった汎用的な型はもちろん、**ユーザーIDやチャンネルIDも型としてサポートされている**のはSlackならではですね。

|型|ショートハンド|説明|
|:--|:--|:--|
|String|`string`|UTF-8 エンコードされた4000byteまでの文字|
|Boolean|`boolean`|`true`か`false`の論理値|
|Integer|`integer`|`-1`, `0` や `31415926535`などのすべての数値|
|Slack User ID|`user_id`|`U18675309`や`W15556162`などのSlackのユーザーID|
|Slack Usergroup ID|`usergroup_id`|`S0614TZR7`などのSlackのユーザーグループID|
|Slack Channel ID|`channel_id`|`C123ABC45`や`D987XYZ65`などのSlackのチャンネルID|


### テーブルのスキーマ定義
Slackアプリでテーブルを使用するにはSlack CLIで生成したテンプレート内の`tables`ディレクトリ内のファイルでデータ構造を定義する必要があります。定義をしたらSlack CLIで `slack deploy` を実行してアプリ用のテーブルを作成します。**「id」列は必ず含める必要があります。**

```.ts
import { DefineTable, Schema } from "slack-cloud-sdk/mod.ts";

export const Reversals = DefineTable("reversals", {
  primary_key: "id",
  columns: {
    id: {
      type: Schema.types.string,
    },
    original_string: {
      type: Schema.types.string,
    },
    reversed_string: {
      type: Schema.types.string,
    },
    user_id: {
      type: Schema.slack.types.user_id,
    },
  },
});
```

:::note alert 
　【テーブルスキーマの変更に関する注意】
・テーブルの名前を変更すると、2つのテーブルになります（古いテーブルとそのデータは引き続き存在します）。
・テーブルに新しい列を追加すると、その列が追加される前に存在していた行のデータが削除されます。
:::
:::note warn
本記事執筆時点ではテーブルのバリデーションは開発中です。現時点では以下のようなものが追加される予定です。
・有効な型のチェック
・重複したテーブル名の検出
・重複した列名の検出
・空の列名や型を見つける
・'id'型の列が存在するかどうかの確認
・削除/名前変更された列の確認
・テーブルの削除／名称変更のチェック
・既存の列でデータ型が変更されていないか
:::

# データ操作
テーブルのデータ操作は

- SlackAPIClientの`call`メソッドで呼び出すパターン
- DefineTablesで定義されたテーブルの`api`を経由して呼び出すパターン

の２通りがあります。後者は前者の糖衣構文なので基本的には後者を使えば良さそうです。

### データ作成
データの作成は`apps.hosted.tables.putRow`でAPIを呼び出します。

```.ts
await client.call("apps.hosted.tables.putRow", {
	table: "reversals", // テーブル名
	row: {// reversalsに追加するレコード
		id: 1,
		original_string: "Hello, world!",
		reversed_string: "!dlrow ,olleH",
		user_id: "W0123456789"
	}
});
```
ショートハンドは`put`メソッドで呼び出せます。この場合は対象となるテーブルはすでに特定されているので引数で与えるのは追加するデータのみでOKです。

```.ts
// Shorthand
const tables = TasksTables.api(client); // TasksTablesはDefineTablesで定義されたTableオブジェクト
tables.put({
	id: 1,
	original_string: "Hello, world!",
	reversed_string: "!dlrow ,olleH",
	user_id: "W0123456789"
});
```

### データ読み込み
データを読み込む方法は「ID指定」、「クエリ」の２通りがあります。

#### ID指定
IDを指定したデータの取得は`apps.hosted.tables.getRow`でAPIを呼び出します。

```.ts
await client.call("apps.hosted.tables.getRow", {
	table: "reversals",
	id: "1"
});
```
ショートハンドは`get`メソッドで呼び出せます。

```.ts
// Shorthand
const tables = Reversals.api(client);// ReversalsはDefineTablesで定義されたTableオブジェクト
tables.get(1);
```

#### クエリ
IDを指定したデータの取得は`apps.hosted.tables.query`でAPIを呼び出します。

```.ts
let result = await client.call("apps.hosted.tables.query", {
	table: "reversals", //テーブル名
	expression: "#created = :today",
	expression_columns: { "#created": "created"},
	expression_values: { ":today": moment().startOf('day').utc().format()}
});
```
ショートハンドは`query`メソッドで呼び出せます。

```.ts
// Shorthand
const tables = Reversals.api(client);// ReversalsはDefineTablesで定義されたTableオブジェクト
tables.query({
	expression: "#created = :today",
	expression_columns: { "#created": "created"},
	expression_values: { ":today": moment().startOf('day').utc().format()}
});
```
expressionで検索条件を指定します。詳細については[ドキュメント](https://api.slack.com/future/tables#query)に記載してあります。

ちなみに引数を指定しないと全件取得が可能です。

```.ts
const tables = Reversals.api(client);
const allItems = await tables.query();
```

### データ更新
データの更新はデータの作成と同じ`apps.hosted.tables.putRow`でAPIを呼び出します。
この際、**既存のIDを指定すると更新処理になります。この時、更新する項目だけでなく全ての項目を引数のデータに加えることが推奨されています。**
> For completeness sake, include all values for the columns in a table.

### データ削除
データの削除は`apps.hosted.tables.deleteRow`でAPIを呼び出します。

```.ts
await client.call("apps.hosted.tables.deleteRow", {
	table: "reversals", // テーブル名
	id: 31415
});
```
ショートハンドは`delete`メソッドで呼び出せます。

```.ts
// Shorthand
tables = Reversals.api(client);// ReversalsはDefineTablesで定義されたTableオブジェクト
tables.delete(31415);
```



# アプリを作りながらテーブルを使ってみる
フレームワーク学習のエントリーポイントとしてよく取り上げられるTodoアプリを題材にデータの処理をしてみます。

### 事前準備
Slack CLIを入れていない場合は[インストール](https://api.slack.com/future/tools/cli#install)します。Denoランタイムがインストールされていない場合はDenoもインストールします。

```
slack login
```
を実行して吐き出される文字をベータ版申し込みをしたワークスペースで送信します。

```
slack auth info
```
でログインが成功しているか確認ができます。

```
slack create my-new-todo-project
```
を実行してカレントディレクトリにプロジェクトディレクトリとファイルを生成します。このときに`-t`フラグを指定してgitやhttp経由で[テンプレートを指定する](https://api.slack.com/future/tools/cli#create)ことも可能です。



※以下テーブルを操作するFunctionsを中心にポイントのみ記載してます。完全なプロジェクトは以下のGithubで公開しています（バリデーションなど省略しているため実用に耐えるものではありませんのでご注意ください）。

https://github.com/07JP27/slack-table-api-sample

### スキーマ定義

``` .ts
export const TodoItems = DefineTable("todos", {
  primary_key: "id",
  columns: {
    id: {
      type: Schema.types.string,
    },
    title: {
      type: Schema.types.string,
    },
    assign_to: {
      type: Schema.slack.types.user_id,
    },
    is_done: {
      type: Schema.types.boolean,
    },
  },
});
```

### 新規作成

```.ts
  async ({ inputs, client }) => {
    const tables = TodoItems.api(client);
    // 別途用意したGUID生成処理を呼ぶ
    const id = Guid.newGuid();
    await tables.put({
      id: id,
      title: inputs.title,
      assign_to: inputs.assignTo,
      is_done: inputs.isDone,
    });

    return await {
      outputs: { id, channel: inputs.channel },
    };
  },
);
```

### 全件取得

```.ts
async ({ inputs, client }) => {
    const tables = TodoItems.api(client);
    const items = await tables.query();

    if (!items.ok) {
      return {
        outputs: {
          result: `Failed to list all indexes because of unknown error.`,
          channel: inputs.channel,
        },
      };
    }

    if (items.rows.length === 0) {
      return {
        outputs: {
          result: `There is no data.`,
          channel: inputs.channel,
        },
      };
    }

    let returnString: string;
    // 一行１データの表示用文字列を作成
    const returnText = items.rows.map((t, i) => {
      returnString = "";
      returnString +=
        `\`${t.id}\` -- ${t.title} -- ${t.assign_to} -- ${t.is_done}`;
      return returnString;
    })
      .reduce((pre, cur) => pre + "\n" + cur);

    return {
      outputs: {
        result: returnText,
        channel: inputs.channel,
      },
    };
  },
```

### 完了としてマーク

```.ts
async ({ inputs, client }) => {
    const tables = TodoItems.api(client);
    // 更新処理で他の項目を与えるために既存データを取得
    const item = await tables.get(inputs.id);

    if (!item.ok) {
      return {
        outputs: {
          result: `Failed to list all indexes because of unknown error.`,
          channel: inputs.channel,
        },
      };
    }

    if (!item.row) {
      return {
        outputs: {
          result: `There is no data.`,
          channel: inputs.channel,
        },
      };
    }
    // 完了フラグ以外は既存データをそのまま与える
    const result = await tables.put({
      id: inputs.id,
      title: item.row.title,
      assign_to: item.row.assign_to,
      is_done: true,
    });

    if (!result.ok) {
      return {
        outputs: {
          result: `Failed to mark.`,
          channel: inputs.channel,
        },
      };
    }

    return {
      outputs: {
        result: "Mark successfully.",
        channel: inputs.channel,
      },
    };
  },
```

### デモ
`タスク追加(1件目）`→`全件取得`→`タスク追加(2件目）`→`全件取得`→`１件目を完了`→`全件取得`
の順番で操作しています。全件取得時の表示内容は 「{ID} -- {タイトル} -- {担当者ID} -- {完了フラグ}」です。
<iframe width="500" height="615" src="https://www.youtube.com/embed/e7YScti6X7k" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>


# 最後に
自前でホスティング環境を用意することなくSlackのホステッド環境でデータストアも行えるのはとても便利だと感じました。
特に開発者目線ではFunctionsやWorkflowなどの抽象化があることで再利用可能でクリーンな構成で開発ができると思います。
大規模なストアにも使えるとのことで、正式リリースするときには料金がかかるのか？なども気になる部分もありますが、全体的にとても良い仕組みだと思いました!

### アプリのブラッシュアップ案
- 完了にマークするタスクをドロップダウンで選択したい(IDを覚えておくのは難しい）
- expire date列を作って期限が近づいたらassign toのユーザーにリマインド
- １タスクを複数ユーザーにアサインできるようにする
- ユーザーの表示をID→メンションにする

### Tables APIの今後に期待しているところ
- テーブルのExplorerツールが欲しい（SQL Management Studio的な）。
- バリデーションの簡略化。
- ID列の自動採番

### Tables APIが使えそうなアイディア

- チャンネルのライフサイクル管理アプリ(プロジェクト用チャンネルなど期限が決まっているチャンネルの延長確認やアーカイブ化）
- 当番通知アプリ

# 参考・関連URL
https://slack.com/intl/ja-jp/blog/developers/faster-simpler-way-build-apps

https://api.slack.com/future/tables

https://github.com/lyohe/swords