履歴情報つきinteger試作

三連休、みなさんいかがお過ごしでしょうか。
私は秦野の万葉倶楽部でお風呂三昧の傍ら、役にたたないPostgreSQL拡張を作ってますw

今回作ったのは、履歴情報つきinteger。この構想自体は数年前からあったけど、久々に作りなおしてみることにした。

実装方針

最初はまっとうにCREATE TYPEしてC言語で作ろうかと思ったわけなんだけど、管理情報を設計しているうちに、
これ、フツーにJSONBで管理できる範疇じゃね?
と思い、方針を完全に変更し、JSONB型の応用例として作ってみることにした。
なので、実装方法もC言語でごりごり書くのではなく、SQL関数とplpgsql関数のみで済ますことに。型自体はJSONBのまま使い、SQL関数をラップすることで履歴情報integerを擬似することにした。

作ったのはこんな関数群。value(jsonb, timestamptz)のみplpgsqlで実装し、あとはSQL関数で済ませるという手抜きっぷり。

関数名 内容 返却型 引数
add_tt_int integer更新の履歴を追加 jsonb src jsonb, value integer
create_leaf_value (内部関数) jsonb value integer
create_tt_int (更新情報付きinteger生成) jsonb value integer
header ヘッダ情報取得 jsonb src jsonb
newest 最新タイムスタンプ取得 timestamp with time zone src jsonb
nums 世代数取得 integer src jsonb
oldest 最古タイムスタンプ取得 timestamp with time zone src jsonb
value 最新の値を取得 integer src jsonb
value 世代番号を指定して値を取得 integer src jsonb, in_t timestamp with time zone
value タイムスタンプを指定して値を取得 integer src jsonb, num integer

使用例

こんな感じで使う。

cretae_tt_int()による履歴情報つきintegerの生成。

まず、挿入時に cretae_tt_int() に整数値を与えて、履歴情報つきinteger(実体はJSONB)を生成する。

test=# CREATE TABLE test (id int, data  jsonb);
CREATE TABLE
test=# INSERT INTO test VALUES (1, create_tt_int(100));
INSERT 0 1
test=# 

「tt ってなんの略よ?」という疑問もあると思うけど、Time-Travelの意味でttというのをつけた。深い意味はあまりない。
それはさておき、これで履歴情報つきintegerが生成された。
値をとりだすときは、value()を使う。

test=# INSERT INTO test VALUES (1, create_tt_int(100));
INSERT 0 1
test=# SELECT value(data) FROM test;
 value 
-------
   100
(1 row)

実際、テーブル内では、どう格納されているかというと、こんなJSONBデータとして格納されている。
そこ、オーバヘッド大きすぎとか言わない。

test=# SELECT jsonb_pretty(data) FROM test;
 {                                                       +
     "header": {                                         +
         "nums": 1,                                      +
         "newest": "2016-03-21 09:52:31.453021+09",      +
         "oldest": "2016-03-21 09:52:31.453021+09"       +
     },                                                  +
     "values": [                                         +
         {                                               +
             "num": 1,                                   +
             "value": 100,                               +
             "timestamp": "2016-03-21 09:52:31.453021+09"+
         }                                               +
     ]                                                   +
 }
add_tt_int()による更新

次に、add_tt_int()を使って、履歴付きで値を100から200に更新する。

test=# UPDATE test SET data = add_tt_int(data, 200) WHERE id = 1;
UPDATE 1
test=# 

value(jsonb)で最新値を取り出す。

test=# SELECT value(data) FROM test WHERE id = 1;
   200

test=# 

更新した200が取得できる。
この状態で、1世代前の値(100)を取得してみる。

test=# SELECT value(data,1) FROM test WHERE id = 1;
   100

test=# 

はい。取得できましたね。

value(jsonb, timestamp with timezone)を使い時刻指定で値を取得

もうちょっと履歴を増やしてみる。

test=# SELECT now();
 2016-03-21 10:06:15.211285+09

test=# UPDATE test SET data = add_tt_int(data, 300) WHERE id = 1;
UPDATE 1
test=# SELECT now();
 2016-03-21 10:06:26.639239+09

test=# UPDATE test SET data = add_tt_int(data, 400) WHERE id = 1;
UPDATE 1
test=# 
test=# SELECT now();
 2016-03-21 10:06:34.204402+09

test=# UPDATE test SET data = add_tt_int(data, 500) WHERE id = 1;
UPDATE 1
test=# 

現在は5つの世代の履歴が格納されている。
最終更新のタイムスタンプは、10時6分38秒くらい。

test=# SELECT nums(data) FROM test WHERE id = 1;
    5

test=# SELECT newest(data) FROM test WHERE id = 1;
 2016-03-21 10:06:38.830039+09

この状態で、10時6分25秒時点のdataの値を参照してみる。この時点では300になっているはず。

test=# SELECT value(data,'2016-03-21 10:06:25'::timestamptz) FROM test WHERE id = 1;
   300

test=# 

はい。取得できましたね。

まあ、こんな感じで使う。
役に立つのかどうかは知らん。

続編のベンチマーク測定で判明するけど、非常に更新世代数が増えると、この方式はとんでもなく遅くなってしまうので、使うとしたらせいぜい数十世代の管理が限界かなぁ、という気はしているが。
なんせ、5世代分の情報格納だけで、こんなJSONBデータになるからなあ・・・

test=# SELECT jsonb_pretty(data) FROM test WHERE id = 1;
                       jsonb_pretty                       
----------------------------------------------------------
 {                                                       +
     "header": {                                         +
         "nums": 5,                                      +
         "newest": "2016-03-21 10:06:38.830039+09",      +
         "oldest": "2016-03-21 09:52:31.453021+09"       +
     },                                                  +
     "values": [                                         +
         {                                               +
             "num": 5,                                   +
             "value": 500,                               +
             "timestamp": "2016-03-21 10:06:38.830039+09"+
         },                                              +
         {                                               +
             "num": 4,                                   +
             "value": 400,                               +
             "timestamp": "2016-03-21 10:06:31.403903+09"+
         },                                              +
         {                                               +
             "num": 3,                                   +
             "value": 300,                               +
             "timestamp": "2016-03-21 10:06:23.426476+09"+
         },                                              +
         {                                               +
             "num": 2,                                   +
             "value": 200,                               +
             "timestamp": "2016-03-21 10:01:11.80119+09" +
         },                                              +
         {                                               +
             "num": 1,                                   +
             "value": 100,                               +
             "timestamp": "2016-03-21 09:52:31.453021+09"+
         }                                               +
     ]                                                   +
 }

実装コード

SQL関数/plpgsql関数として実装しているので、ちょい長いけど、ここに書いちゃう。
このコードはPostgreSQl 9.5以降のバージョンで動作します。
(jsonb_build_object() を json_build_object()::jsonb に書き換えれば、9.4でも動くかもしれないが未確認)

--
-- tt_type
--

--
-- integer value to jsonb value.
-- (internal)
--
CREATE OR REPLACE FUNCTION create_leaf_value(value integer) RETURNS jsonb AS $$
  SELECT jsonb_build_object('value', value);
$$ LANGUAGE sql;

--
-- integer value to tt_int(jsonb) value.
-- 
CREATE OR REPLACE FUNCTION create_tt_int(value integer) RETURNS jsonb AS $$
  SELECT 
    jsonb_build_object(
      'header', jsonb_build_object(
        'nums', 1,
        'newest', now()::text,
        'oldest', now()::text),
      'values', jsonb_build_array(
        jsonb_build_object(
          'value', value, 
          'timestamp', now()::text, 
          'num', 1
        )
      )
    );
$$ LANGUAGE sql;

--
-- add integer to tt_int(jsonb) 
--
CREATE OR REPLACE FUNCTION add_tt_int(src jsonb, value integer) RETURNS jsonb AS $$
  SELECT
    jsonb_build_object(
      'header', jsonb_build_object(
        'nums', (src #>> '{header, nums}')::integer + 1,
        'oldest', (src #>> '{header, oldest}')::text,
        'newest', now()::text
      ),

      'values', jsonb_build_array(
        jsonb_build_object(
          'value', value,
          'num', (src #>> '{header, nums}')::integer + 1,
          'timestamp', now()::text
        )
      ) || (src -> 'values')::jsonb
    );    
$$ LANGUAGE sql;

--
-- getter functions
--

--
-- get current value
--
CREATE OR REPLACE FUNCTION value(src jsonb) RETURNS integer AS $$
  SELECT (src #>> '{values, 0, value}')::integer;
$$ LANGUAGE sql;

--
-- get value by generation number
--
CREATE OR REPLACE FUNCTION value(src jsonb, num int) RETURNS integer AS $$
  SELECT (src #>> ('{values, ' || num || ', value}')::text[] )::integer;
$$ LANGUAGE sql;

--
-- get value by timestamp
--
CREATE OR REPLACE FUNCTION value(src jsonb, in_t timestamptz) RETURNS integer AS $$
DECLARE
  newest timestamptz;
  oldest timestamptz;
  n integer;
  nums integer;
  t timestamptz;
BEGIN
  newest := (src #>> '{header,newest}')::timestamptz ;
  IF in_t >= newest THEN
    RETURN (src #>> '{values, 0, value}')::integer;
  END IF;

  oldest := (src #>> '{header,oldest}')::timestamptz ;
  IF in_t < oldest THEN
    RETURN null;
  END IF;

  nums := (src #>> '{header,nums}')::integer;
  FOR n IN 0 .. nums - 1 LOOP
    t = (src #>> ('{values, ' || n || ',timestamp}')::text[])::timestamptz;
    IF in_t >= t THEN
      RETURN (src #>> ('{values, ' || n || ',value}')::text[])::integer;
    END IF;
  END LOOP; 
END;
$$ LANGUAGE plpgsql;


--
-- get metadata
--
CREATE OR REPLACE FUNCTION header(src jsonb) RETURNS jsonb AS $$
  SELECT (src -> 'header');
$$ LANGUAGE sql;

CREATE OR REPLACE FUNCTION nums(src jsonb) RETURNS integer AS $$
  SELECT (src #>> '{header,nums}')::integer;
$$ LANGUAGE sql;

CREATE OR REPLACE FUNCTION newest(src jsonb) RETURNS timestamptz AS $$
  SELECT (src #>> '{header,newest}')::timestamptz;
$$ LANGUAGE sql;

CREATE OR REPLACE FUNCTION oldest(src jsonb) RETURNS timestamptz AS $$
  SELECT (src #>> '{header,oldest}')::timestamptz;
$$ LANGUAGE sql;