RDBからkintoneへのデータ移行 ～kintoneエンジニアのためのSQL～

# はじめに

kintone2アドベントカレンダー、8日目の記事です！
需要がありそうで事例が公開されてなかった「リレーショナルデータベース(RDB)→kintoneへのデータ移行」について、ガッツリと実験したのでノウハウ書いてみたいと思います。

対象読者は、RDB/SQLの基礎知識がある人です。
RDBに慣れてる人がkintoneに入門する場合も役に立つのではないかと思います。

検証はPostgreSQLで行いました。MySQL/Oracleなどでは挙動が違う可能性があるので、適宜読み替えてください。関数など多少違っても、「不可能」ってことはないと思います。

# kintoneとRDBの比較

まず初めにkintoneとRDBを比較して、リレーションをkintone上でどう表現するのか整理しましょう。

## アプリ vs テーブル vs サブテーブル

kintoneの基本単位は「アプリ」です。この言葉が意外と曲者で、kintoneアプリって、パソコンアプリ、スマホアプリ、一般的なWebアプリなどに比べると粒度が小さいですよね。小さなアプリを複数連携させてこそ、kintoneの真価を発揮できます。

僕は複数のアプリを1つのスペースにまとめて、スペース内のアプリ同士をルックアップで連携しながらシステムを作ることが多いです。その1スペースが、世間一般で「アプリ」と呼ばれるような粒度になるわけですね。

さて、そんな風にアプリ間の連携を考えながら設計するのは、RDBでのデータモデリングにとても近い感覚があります。

kintoneの「アプリ」は、RDBでの「テーブル」相当と考えると、DBに慣れてる人はしっくり来るんじゃないでしょうか。kintoneで「テーブル」といえば「サブテーブル」を指すのがややこしいですが、「サブ」というからには「メイン」が事実上あるわけで、「メインテーブル = アプリ」という解釈で良いのだと思います。[^1]

てわけで、この記事では以下のように用語を使い分けます

* **アプリ**: kintoneアプリ
* **サブテーブル**: kintoneサブテーブル
* **テーブル**: RDBテーブル

「kintoneのテーブル」という言い方は紛らわしいので使わず、「サブテーブル」に統一します。
（もし書いちゃってたら指摘してね）

## リレーションの表現方法

### 1:Nの結合
RDBでいう「1:Nのリレーション」をkintoneで表現する例として、以下のような「顧客」「売上」という2エンティティを考えてみましょう。`顧客:売上 = 1:N` です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/e1ff339d-3b5c-c680-b93c-24bee3d35ef7.png)

kintoneでは大きく3通りの選択肢が考えられます。

* 顧客アプリ、売上アプリをそれぞれ作成
  * 売上から顧客をルックアップ
* 顧客アプリのみ作成
  * サブテーブルに売上を登録
* 売上アプリのみ作成
  * ドロップダウンフィールドの選択肢として顧客を登録

表で比較すると、こんな感じ。

|   | 2アプリ作成 | 顧客アプリのみ | 売上アプリのみ |
|---|---|---|---|
| 顧客1件     | 1レコード  | 1レコード  | ドロップダウンフィールド |
| 売上1件     | 1レコード  | サブテーブル1行  | 1レコード  |
| リレーション | ルックアップ | 行追加  | ドロップダウン選択  |
| メリット | 顧客・売上どちらも多い場合の安定感 | 売上追加のUIが直感的 | 顧客名を変更したら全売上に自動反映  |
| デメリット | 顧客名を変更したら再ルックアップ必要 | CSVでの売上一括更新が不可 | 住所・TELなどを複数フィールドに分けられない |


RDB脳で設計するとつい1テーブル＝1アプリで作っちゃいがちですが、ユースケースによっては「マスタアプリは作らずにドロップダウンで十分」みたいに、色々とkintoneならではの最適解を考える必要があります。

この辺が楽しくもあり、悩ましくもあるんですよねー。
（うんうん頷いてくれるのが目に浮かびますよ皆さん！）

### N:Mの結合
「多対多」とも言いますね。
上記の1:Nを発展させて「商品」「顧客」「売上」の3エンティティで考えてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/fe38cb20-b98d-8137-9452-47dd8c7d90ca.png)

`商品:売上 = 1:N` であり、間接的に`商品:顧客 = N:M` となります。
これをkintone上で表現する場合、代表的な例はこんな3通りかな。

* 商品アプリ、顧客アプリ、売上アプリをそれぞれ作成
  * 売上から商品・顧客をそれぞれルックアップ
* 商品アプリ、売上アプリの2つを作成
  * 売上から商品をルックアップ
  * 売上のドロップダウンフィールドで顧客を選択
* 商品アプリ、顧客アプリの2つを作成
  * 顧客のサブテーブルに売上を登録
  * 売上サブテーブル内で商品をルックアップ

本当はもっとバリエーション考えられますが省略。
特に最後の「サブテーブル＆ルックアップを組み合わせると、2アプリで多対多が実現できる」
って方法がkintoneの面白いところですね。

# データ移行 RDB→kintone

各種パターンが出揃ったところで、いよいよデータ移行を考えましょう。

RDBMSの機能でテーブル別にCSVをdumpすることも可能ですが、SQLで`SELECT`文を書いて、**kintoneにそのまんまCSVインポートできる形**で出力してあげるのがより良いと思います！

## オンラインツール SQL Trainer

弊社SonicGardenの@mah_lab氏が開発した、オンラインで`SELECT`文を試せるツールがあります。このツールでそのまま流せるSQLを書いて、kintoneにインポートするところまでやってみたいと思います！
http://sql-study.mah-lab.com/


たくさんテーブルが用意されていますが、今回使うのは親1つに子2が並列してるこんな3テーブル[^2]。ブログサービスのユーザー・記事・アクセス履歴と考えてください。


* service_users(ユーザ)
* entries(記事)
* access_histories(アクセス履歴)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/c83ccec2-f08b-4de6-e868-bcb8fc114825.png)

これは`N:M`結合ではなく、`1:N`結合が別々に2つある状態です。
`1:N`のやり方を確実に押さえれば`N:M`はその応用でいけちゃうので、`N:M`に特化した解説はしません。

試しに、普通にSQLを書いて実行してみましょう。これをツールにコピペして「Run」ボタンを押してください。

```sql
SELECT
  u.name,
  e.id as entry_id,
  e.body as entry_body
FROM
  service_users AS u
  INNER JOIN
    entries AS e ON
    u.id = e.service_user_id
ORDER BY
  e.id
LIMIT 3
```

ユーザ名とブログ記事が結合されて、こんな風に出力されればOK！
サンプルの`SELECT`文を即試せるWebツールがあるって最高ですねー :smile: 


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/d9b92bdf-d020-2650-1379-eb2d2ac53b29.png)

出力結果の表は、ブラウザ画面をドラッグ選択してExcelやGoogleスプレッドシートに奇麗にコピペできます。その先は普通にファイル保存してkintoneへインポート可能ですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/974fa074-2ea2-3f4a-3e67-5ea7b3a8ebab.png)

ちなみにSQL文の整形にはこのオンラインツールが便利です。この記事のSQL整形にも使わせてもらいました。
[SQLフォーマッターFor WEB](https://atl2.net/webtool/sql%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%82%BF%E3%83%BCfor-web/)

これで準備OK！ここからkintone向けの整形をやっていきます。
（やっと本題に入れるｗ）


## 各パターン別のSQL例

### 1テーブル → 1アプリ

まずは基本形。`JOIN`はナシで、
`service_users`テーブルだけkintoneに移行してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/c34c3c01-e1a2-fbdb-f21a-fbc4796325f1.png)


```sql
SELECT
  id AS "ユーザID",
  name AS "ユーザ名"
FROM
  service_users
ORDER BY
  id ASC
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/75cbe954-3b3d-1870-7f08-91c2a52c09da.png)

`AS` でkintone側の`フィールド名`と一致させておくのがポイントです。そうすると、CSVインポート画面での紐づけが自動的にされますからね。

kintone側の`フィールドコード`は自由ですが、移行元DBの`テーブル名.カラム名`が連想できるような名前にしておくと、移行後にトラブったときの照らし合わせなどスムーズになると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/17f327a0-c88f-42ee-acde-e7775cd9d92d.png)


### 1親テーブル, 1子テーブル → 2アプリ

次は1つだけ`JOIN`する例ですが、kintone側を2アプリ構成(記事→ユーザをルックアップ)にすると、SQL的にはシンプルにすみます。アプリ2つ分のCSVを分けて作り、それぞれインポートする形ですね。

**ユーザアプリ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/c34c3c01-e1a2-fbdb-f21a-fbc4796325f1.png)

**記事アプリ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/09fe090a-2a11-e62b-64db-fdf8178ff264.png)

```sql:ユーザ
SELECT
  id AS "ユーザID",
  name AS "ユーザ名"
FROM
  service_users
ORDER BY
  id ASC
```

```sql:記事
SELECT
  e.id AS "記事ID",
  u.id AS "ユーザID",
  e.body AS "記事"
FROM
  entries AS e
  INNER JOIN
    service_users AS u ON
    u.id = e.service_user_id
ORDER BY
  "記事ID" ASC
```

ルックアップに関する制約がポイントですね。

* ユーザのSQLは変わらないが、ユーザIDフィールドを重複禁止にしておく（ルックアップキーにするので）
* 記事のSQLに「ユーザ名」は不要（ルックアップコピーされるので）
* インポートは必ずユーザー→記事の順で行う


### 1親テーブル, 1子テーブル → 1アプリ, 1サブテーブル

さて、だんだん複雑になります。サブテーブルの登場。
ユーザアプリのサブテーブルに、ブログ記事を直接書いてしまいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/f7089e00-00e9-0d32-cdeb-4a4801c8a827.png)


サブテーブルをCSVインポートするのって、列構成が面倒くさいんですよねー。僕がkintoneを使い始めた時はそもそもインポート不可能で、途中で機能追加されたけども使いにくいー！って思ったのを覚えています。

このジョイゾーさんの記事がとってもわかりやすいので必読！
[【17年8月度版】テーブルデータのCSV読み込みが可能になりました](https://www.joyzo.co.jp/blog/4179)

一番のポイントが**レコードの開始行に`*`を入力する**というルール。これをSQLで表現するのに一工夫いります。

まずはSQLと出力結果から見ましょう。

```sql
SELECT
  CASE
    WHEN u.id <> COALESCE(LAG(u.id) OVER(ORDER BY u.id), 0) THEN '*'
  END AS "レコードの開始行",
  u.id AS "ユーザID",
  u.name AS "ユーザ名",
  e.id AS "記事ID",
  e.body AS "記事"
FROM
  service_users AS u
  LEFT JOIN
    entries AS e ON
    u.id = e.service_user_id
ORDER BY
  "ユーザID" ASC,
  "記事ID" ASC
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/227da100-8d2d-319b-d9be-22eb1124d244.png)

これで問題なくインポートできます！いい感じ。

さてSQLの難易度は一気に飛躍しましたが、今回のポイントはこのへん。

* `LEFT JOIN`でブログ記事が存在しないユーザも漏れなく取得
* レコードの開始行を示す`*`を出力するには
  * `CASE`による条件分岐
  * ウィンドウ関数`LAG`で前の行と比較、ユーザIDの境目を判定
  * 1行目の`LAG`は`NULL`になるので、`COALESCE`を使って`NULLを0に置換`する

ウィンドウ関数は最近この記事で知って、とっても便利だなーと思ったので早速`LAG`使ってみました。
[そろそろSQLのウィンドウ関数を理解したい - 連載1/3話](https://qiita.com/w-sato-ist/items/63600a3ab84aad38e879)

`LAG`関数の解説はこの記事が丁寧でわかりやすい。
[【BigQuery】LAG関数，LEAD関数の使い方](https://qiita.com/kota_fujimura/items/cff732bb9acb47510a03)

今回は「ユーザIDの境目がレコードの境目」なので、「前の行とユーザーIDが異なる場合は`*`」ってロジックです。スマートですね。
しかし、もし`COALESCE`使わず単純にこう書いた場合、

```sql
  CASE
    WHEN u.id <> LAG(u.id) OVER(ORDER BY u.id) THEN '*'
  END AS "レコードの開始行",
```

1行目だけ`*`は出ずに空欄になってしまうんです。なぜか？

先頭行の`LAG`の結果は、その前の行が存在しないので`NULL`です。
普通のプログラミング言語だと、`1 != null`は`true`になるじゃないですか。ところがSQLは独特で、`1 <> NULL`は「`true` `false`どっちでもなく、**`unknown`という第3の値**」になっちまうんです！

[3値論理とNULL (1/3)：CodeZine（コードジン）](https://codezine.jp/article/detail/532)
> ```sql
> --以下の式は全部 unknown に評価される。
> 1 = NULL
> 2 > NULL
> 3 < NULL
> 4 <> NULL
> NULL = NULL
> ```


`1 <> NULL` → `unknown`として評価されると、`CASE`文の`THEN`には行かないので、結果は`*`じゃなく空欄になってしまうんですね。
これがSQLの仕様ですが、そんな中でも、1行目にちゃんと`*`を出したい。

これを解決するにはいくつか方法があるけど、今回は「`<>`で比較する前に`NULL`を数値に置換しておく」ことにしました。そのために使うのが`COALESCE`。

```sql
  CASE
    WHEN u.id <> COALESCE(LAG(u.id) OVER(ORDER BY u.id), 0) THEN '*'
  END AS "レコードの開始行",
```

第二引数には0を入れました。「ユーザIDは必ず1以上」とデータから確認できているので、「0であれば、必ず`<>`での比較結果は`true`」ということです。

`COALESCE`についての解説は、この記事が分かりやすかった。
[SQL関数coalesceの使い方と読み方](https://doruby.jp/users/akio0911_on_rails/entries/SQL_coalesce_)

### 1親テーブル, 2子テーブル → 1アプリ, 2サブテーブル

さーいよいよ最後。1アプリ内に2つのサブテーブルが存在する場合です！
「アクセス日時」のkintoneフィールドは、「日時型」にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/73a8faeb-6bcb-a167-7ecd-e7b6009df7ba.png)

今回はまず、最終的に作りたい表から見てみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/1b78008c-4255-67af-20b3-cdd7c94b1950.png)

サブテーブル2つをインポートする場合に一番気を付けなければいけないのが、**CSVの1行内に複数サブテーブルの値を載せてはいけない**ということです。

仮にサブテーブル行が1行ずつしかなくても、こんな表をインポートしようとすると叱られます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/4607743f-d248-910f-2324-8955344d6109.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/b2a19dfd-f549-19b9-769a-6b63887f6a03.png)


以下の2パターンならどっちでもOK。（インポート結果も同じ）
前者のように親データとサブテーブルのデータは被って良いんだけど、全く被らない後者のスタイルの方が作りやすいので、後者の作り方を解説します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/6819fa9d-4a7d-a7d7-ba94-a91076c1c5a3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24333/bd9662ce-dc2b-95c8-8597-87f15428d82a.png)

サブテーブル1つバージョンとはガラッと変わり、こんなSQLにしてみました。

```sql
-- ユーザ x アクセス履歴の結合
SELECT
  '' AS "レコードの開始行", -- UNION用の空列
  u.id AS "ユーザID",
  u.name AS "ユーザ名",
  h.id AS "アクセス履歴ID",
  to_char(h.accessed_at, 'YYYY-MM-DD hh:mm') AS "アクセス日時",
  NULL AS "記事ID", -- UNION用の空列
  NULL AS "記事" -- UNION用の空列
FROM
  service_users AS u
  INNER JOIN
    access_histories AS h ON
    u.id = h.service_user_id
UNION
-- ユーザ x 記事の結合
SELECT
  '' AS "レコードの開始行", -- UNION用の空列
  u.id AS "ユーザID",
  u.name AS "ユーザ名",
  NULL AS "アクセス履歴ID", -- UNION用の空列
  NULL AS "アクセス日時", -- UNION用の空列
  e.id AS "記事ID",
  e.body AS "記事"
FROM
  service_users AS u
INNER JOIN
  entries AS e ON
  u.id = e.service_user_id
UNION
-- ユーザ（結合なし）
SELECT
  '*' AS "レコードの開始行",
  u.id AS "ユーザID",
  u.name AS "ユーザ名",
  NULL AS "アクセス履歴ID", -- UNION用の空列
  NULL AS "アクセス日時", -- UNION用の空列
  NULL AS "記事ID", -- UNION用の空列
  NULL AS "記事" -- UNION用の空列
FROM
  service_users AS u
ORDER BY
  "ユーザID" ASC,
  "レコードの開始行" DESC,
  "アクセス履歴ID" ASC,
  "記事ID" ASC
```

今回のポイントはこのへん。行数は長いけど、難易度はさっきより低いかも。

* `SELECT`文を複数書いて、`UNION`で統合
  * それぞれのサブテーブルに存在しない列は`NULL`を指定して何も出さない
  * せっかく`UNION`使うので、レコードの開始行も`LAG`じゃなく単独`SELECT`でシンプルに
* 日時はそのまま出すと秒も付いてきてインポートができないので、`to_char`でフォーマット

`JOIN`の「結合」に対して、`UNION`は「統合」と訳すようですね。同じ列構成の表を上下に並べるので、`SELECT`句での列指定をしっかりやっておく必要があります。
`CASE`を駆使すれば同様の結果は出せるかもしれないけど、`UNION`の方がシンプルに書けて良いと思います。

詳しい解説はこちらを！
[SELECT文を統合する「UNION」：SQL実践講座（9） - ＠IT](https://www.atmarkit.co.jp/ait/articles/0103/14/news001.html)

この方法はサブテーブル1つでも3つでも使えるので、「RDB→kintoneサブテーブルへの移行なら`UNION`」と覚えとくだけでも良さそうです！

後半はSQLそのものの勉強みたいになっちゃいましたが、RDBからkintoneへのデータ移行、この記事で紹介した4種類の方法を使い分ければ、必ずやスムーズにCSVが作れることでしょう！

# おわりに

ここまで色々と書いといて言うのもなんですが、「本当にそのデータ移行、必要ですか？」というのは今一度問いたい。

RDBとkintoneは似ている部分もあるけど全然違います。なんだかんだでRDBの扱いやすさはエンジニアにとっては素晴らしく、深い部分まで行くとkintoneでは太刀打ちできません。RDB大好き人間のワタクシ、正直これまで、kintoneのDBとしての使いづらさにはかなり泣かされてきました。

RDBでガッツリ正規化されたデータベースを、本当にkintoneに移行するべきか？
移行するにしても、全データをkintone上で再現するのが最適なのか？クラウドRDBサービスを併用するのはどうか？
などなど、色々な選択肢があります。銀の弾丸はありませんが、ケースバイケースでしっかりと考えてアーキテクチャを検討しましょうね。



[^1]: 実際、デバッグコンソールで`cybozu.data.page.FORM_DATA.schema`と叩くと`table` `subTable` というプロパティが入っています。`subTable`は当然サブテーブルの情報、`table`にはアプリ直下の情報が入っているので、やはり内部的には「メインテーブル」っぽい概念で間違いなさそう。（kintone黎明期には別の用語で呼んでいたのかもしれません）

[^2]: 実は`entries`テーブルは元々なかったんですが、この記事書くために頼んで追加してもらいましたｗ