kintone 同期処理（cli-kintone の updateKey指定による複数レコード更新）

kintoneアプリ間のレコード同期処理を cli-kintone を使い行います。

# 同期シナリオ

* レコードのバックアップ処理
* 定期的なバックアップ

# 環境

* macOS 10.13.6
* Ruby 2.5.0
* cli-kintone 0.9.4

# 前提条件

* 更新用のキーフィールドを用意する。（今回はフィールドコード：顧客コードを用意）
* 更新用のキーは **数値、必須、重複無し** 、1から順に割り振られている。(1,2,3..,n)
* 処理は更新元のマスタアプリからバックアップ先のアプリへの一方向。
*  **テーブルの更新は無い。** 

# 処理概要

バックアップ先のレコードの最終更新日付を取得して、更新元のマスタアプリ(MA)のレコードの最終更新日付と比較し
バックアップ先の最終更新日付以降に更新されたマスタアプリのレコードをバックアップ先アプリ(BA)へ更新します。

1. バックアップ先のレコードの最終更新日付を取得（例：更新日時→"2019-10-07T13:22:00Z"）
2. バックアップ先の最終更新日付以降に更新された更新元のレコードを取得
3. 更新用のデータに変換
4. 取得したマスタのレコードをバックアップ先に更新

# 全体コード

```put_records_by_updatekey.rb
require 'minitest/autorun'
require 'rubygems'
require 'bundler/setup'
require 'dotenv/load'
require 'open3'
require 'csv'
require 'stringio'
require 'date'
require 'pp'

SUBDOMAIN = ENV['SUBDOMAIN']
APP_FROM = ENV['APP_FROM']
API_FROM = ENV['API_FROM']
APP_TO = ENV['APP_TO']
API_TO = ENV['API_TO']
SELECT_COLUMN = ENV['SELECT_COLUMN']
UPDATE_COLUMN = ENV['UPDATE_COLUMN']
LAST_UPDATE_COLUMN = ENV['LAST_UPDATE_COLUMN']

# 1. バックアップ先のレコードの最終更新日時を取得
def fetch_lastupdate_by_backupapp(domain, app_to, api_to, select_column)
  query = '"order by 更新日時 desc limit 1"'
  cap_str = %Q(cli-kintone -d #{domain} -a #{app_to} -c '\"#{select_column}\"' -t #{api_to}  -q #{query})
  out, err, status = Open3.capture3(cap_str)
  if (err != "")
    pp err; pp status; exit
  end
  if (out.size > 0)
    begin
      lines = []
      out.each_line { |line| lines << (line.chomp).parse_csv }
      lines[1][1]
    rescue => e
      pp e
      exit
    end
  else
    return 0
  end
end

# 2. バックアップ先の最終更新日時以降に更新された更新元のレコードを取得
def fetch_update_records_by_master(domain, app_from, api_from, select_column, datetime)
  query = "\"更新日時 > \\\"#{datetime}\\\" order by 更新日時 asc\""
  cap_str = %Q(cli-kintone -d #{domain} -a #{app_from} -c '\"#{select_column}\"' -t #{api_from}  -q #{query})
  out, err, status = Open3.capture3(cap_str)
  puts out
  if (err != "")
    pp err; pp status; exit
  end
  out
end

# 3. 更新用のデータに変換
def convert_records_by_updatekey(update_column, records_string)
  csv = CSV.new(records_string, headers: true)
  csv_out = CSV.new(StringIO.new) << CSV.new(update_column).shift
  csv.each {|row| csv_out << row}
  csv_out.string
end

# 4. 取得したマスタのレコードをバックアップ先に更新
def update_as_backupapp(domain, app_to, api_to, records_string)
  cap_str = %Q(cli-kintone --import -d #{domain} -a #{app_to} -t #{api_to})
  csv_out =  StringIO.new
  csv_out << records_string
  out, err, status = Open3.capture3(cap_str, :stdin_data => csv_out.string)
  if (err != "")
    pp err; pp status; exit
  end
  out
end

# UnitTest
class AddDifferenceRecordsTest < Minitest::Test
  def test_1
    puts __method__
    records = fetch_lastupdate_by_backupapp(SUBDOMAIN, APP_TO, API_TO, LAST_UPDATE_COLUMN)
    puts records.class
    io_string = StringIO.new(records)
    ary = io_string.readlines
    assert_equal 1, ary.size
    assert_equal DateTime.parse("2019-10-10T10:07:00Z").class, DateTime.parse(ary[0]).class
  end
  def test_2
    puts __method__
    records = fetch_update_records_by_master(SUBDOMAIN, APP_FROM, API_FROM, SELECT_COLUMN, "2019-10-07T13:22:00Z")
    sio = StringIO.new(records)
    ary = sio.readlines
    assert_equal 5, ary.size
  end
  def test_3
    puts __method__
    records = <<EOS
"住所","担当者名","部署名","FAX","顧客コード","顧客名","備考","郵便番号","TEL","メールアドレス"
"岐阜県岐阜市××××","下山 達士","情報システム部","050-××××-××××","20","金都運総研","備考欄更新","5010001","090-××××-××××","shimoyama_tatsuhito@example.com"
"大阪府大阪市北区梅田××××","上野 裕太郎","経理部","050-××××-××××","14","有限会社亀山","","5300001","090-××××-××××","ueno_yuujirou@example.com"
EOS
    str = convert_records_by_updatekey(UPDATE_COLUMN, records)
    sio = StringIO.new(str)
    header = sio.readline.chomp
    assert_equal UPDATE_COLUMN, header
  end
  def test_4
    puts __method__
    records = <<EOS
"住所","担当者名","部署名","FAX","*顧客コード","顧客名","備考","郵便番号","TEL","メールアドレス"
"岐阜県岐阜市××××","下山 達士","情報システム部","050-××××-××××","20","金都運総研","備考欄更新","5010001","090-××××-××××","shimoyama_tatsuhito@example.com"
"大阪府大阪市北区梅田××××","上野 裕太郎","経理部","050-××××-××××","14","有限会社亀山","","5300001","090-××××-××××","ueno_yuujirou@example.com"
EOS
    ret = update_as_backupapp(SUBDOMAIN, APP_TO, API_TO, records)
    assert_equal false, ret !~ /SUCCESS/
  end
end
```

# 処理説明

主な処理を説明します。

## 1. バックアップ先のレコードの最終更新日時を取得

バックアップ先のアプリに対して、クエリで更新日時の降順でソートした先頭１レコードを取得します。
今回は、最新の更新日時だけ取得できれば良いので、 `order by 更新日時 desc` でレコードを日時の新しい順の並びで取得し、`limit 1` でその先頭1レコードだけを取得しています。

 **「order by 句 を省略した場合は、レコードIDの降順(desc)で返されます」** 

ちなみに、`order by 更新日時` でも良いです。デフォルトは desc なので省略ができます。 

```ruby
  query = '"order by 更新日時 desc limit 1"'
```

## 2. マスタアプリから、バックアップ先の最終更新日時以降に更新されたレコードを取得

クエリ指定でマスタアプリのレコードを取得します。

日時の指定が少し読みにくいですが、クエリは下記のようになります。

`"更新日時 > \"2019-10-07T13:22:00Z\" order by 更新日時 asc"`

日付を囲むダブルクォーテーションをバックスラッシュでエスケイプします。

datetimeには 1.で取得したバックアップ先の最終更新日時がセットされます。
（例："2019-10-11T10:15:00Z"）

```ruby
  query = "\"更新日時 > \\\"#{datetime}\\\" order by 更新日時 asc\""
```

例えば、毎日決まった時刻に **「前日の更新分を定期取得する」** のなら、更新日時の条件は `FROM_TODAY` 関数を使って、引数に `(-1, DAYS)` (1日前の指定) を使って
`"更新日時 > FROM_TODAY(-1, DAYS) order by 更新日時 asc"` でも良いかも知れません。

## 3. 更新用のデータに変換

cli-kintone を使う最大のメリットはレコード更新にあるのでは無いかと個人的に思っています。[REST API を使うと結構大変](https://developer.cybozu.io/hc/ja/articles/201941784#step2)。

更新系は、レコードIDを使う方法と重複禁止フィールドをキーにする方法の2つがありますが、重複禁止フィールドをキーにする方法はcli-kintoneなら、読み込むCSVのヘッダーの項目に、アスタリスク(*)を付けるだけで行けます。 

`"住所","担当者名","部署名","FAX","*顧客コード","顧客名","備考","郵便番号","TEL","メールアドレス"
`

今回は、プログラムでアスタリスクを付けた更新用のヘッダーを用意して、2.で取得したデータのヘッダーと入れ替える処理をしています。

## 4. 取得したマスタのレコードをバックアップ先に更新

3.で作成した更新用のデータを `--import` を指定して標準出力から読み込みます。

```ruby
  %Q(cli-kintone --import -d #{domain} -a #{app_to} -t #{api_to})
```


# 参考リンク

## cli-kintone関連

* [はじめようkintone コマンドライン](https://developer.cybozu.io/hc/ja/articles/115000297083)
* [第3回 レコードの更新をしてみよう](https://developer.cybozu.io/hc/ja/articles/115001103666)
* [レコードの一括更新](https://developer.cybozu.io/hc/ja/articles/201941784#step2)
* [クエリ指定](https://developer.cybozu.io/hc/ja/articles/202331474#step2)
* [kintone コマンドラインツールの使い方](https://developer.cybozu.io/hc/ja/articles/202957070-kintone-%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3%E3%83%84%E3%83%BC%E3%83%AB%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9#step7)
* [定期実行でデータの同期を実現するスマートな方法 その1〜cli-kintone編〜
](https://developer.cybozu.io/hc/ja/articles/217964263-%E5%AE%9A%E6%9C%9F%E5%AE%9F%E8%A1%8C%E3%81%A7%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E5%90%8C%E6%9C%9F%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%81%AA%E6%96%B9%E6%B3%95-%E3%81%9D%E3%81%AE1-cli-kintone%E7%B7%A8-)
* [https://github.com/kintone/cli-kintone](https://github.com/kintone/cli-kintone)
* [https://developer.kintone.io/hc/en-us/articles/115002614853](https://developer.kintone.io/hc/en-us/articles/115002614853)
* [kintoneコマンドラインツールでUPSERTしてみた](https://qiita.com/ushiron/items/40142dfd1ba873bbfb6f)

## Ruby関連

* [Open3ライブラリ](https://docs.ruby-lang.org/ja/latest/class/Open3.html)
* [Fileクラス](https://docs.ruby-lang.org/ja/latest/class/File.html)
* [CSVライブラリ](https://docs.ruby-lang.org/ja/latest/class/CSV.html)
* [StringIOクラス](https://docs.ruby-lang.org/ja/latest/class/StringIO.html)
* [ヒアドキュメント](https://docs.ruby-lang.org/ja/latest/doc/spec=2fliteral.html#here)
* [minitest/unit](https://docs.ruby-lang.org/ja/2.1.0/library/minitest=2funit.html)
* [DateTimeクラス](https://docs.ruby-lang.org/ja/latest/class/DateTime.html)