Blockchain Tableとビュー、INSTEAD OFトリガーを組み合わせて更新が入る情報をいい感じに扱えるようにしてみた
なんの話
- Oracle DatabaseのBlockchain Tableは追記オンリーなので、Blockchain Table上である情報の更新を表すにはバージョン番号を振りつつ新しい行を追加していく
- Blockchain Tableとビュー(VIEW)を組み合わせると「ある情報の最新のバージョン」だけを抽出できて便利
- さらにビューに対してINSTEAD OFデータベーストリガーを仕掛けておくと、ビュー上の最新断面に対して更新操作を行うことができるようになり、裏でBlockchain Tableに履歴が追記されていく
★2023/5/8 追記:Oracle Database 23cで追加された機能強化でもう少し簡単、便利に実装できるようになりました。23c以降でこうしたことをやりたい場合はこちらのポストを参照ください。
追記オンリーのBlockchain Tableで情報の更新を表現するには
Oracle Databaseの機能にBlockchain Tableというのが追加されたよ、という話を前にしました。そんなん知らんという方はまずこちらのエントリとその中のスライドを見てみてください。
で、このBlockchain TableはINSERT以降のUPDATEが不可、つまり追記オンリーで更新不可のテーブルです。このような特性を持つBlockchain Tableで扱いやすいデータはまず、ログやイベントの保存です。これらは保存して以降変更が入らないのがもともとの性質であるため、更新不可という制約が特に足かせになりません。
一方で、アプリケーションにとっては、次々と情報が更新されていくある事物に関してのデータを扱いたい場面があります。あります、というか多くのデータはそのような、生成されて以降に状態が変化していくある事物に関しての情報でしょう。ここではこのような種類の情報に関してのデータを、便宜的にステートフルオブジェクトのデータと呼称します。
こうしたステートフルオブジェクトのデータを追記オンリーのBlockchain Tableで扱うにはどうしたらいいでしょうか?すぐに思いつくのは、データにバージョン番号を表す属性を付与しておき、追記するたびにバージョン番号をインクリメントしていきつつ複数バージョンを保持する方法です。
ここからは具体的なデータ構造の例で説明したほうがわかりやすいと思うので例を示します。
例:BC_DOCUMENTテーブル
以下の例のようなBlockchain Tableになります。なお例で使ったDDL、DML、SQLなどは記事の最後にまとめて記載します。
ここでの例;bc_document
テーブルでは、一部のドキュメントデータベースのお作法を真似て、あるステートフルオブジェクトのIDをdocument_id
というカラムに、その内容をjson_content
というJSON型カラムで扱っています。そして、バージョン番号をrevision
というカラムに格納します。また、テーブル上ではdocument_idとrevisionの組み合わせで一意となるため、このふたつを複合プライマリーキーとして設定しています。
なお、ここで内容部分をJSON型にしたのは、説明上あまりカラムやリレーションを増やさずにシンプルにしておきたいからです。同時に、このようにBlockchain TableでJSON型を使うのはしばしば良いプラクティスになり得ます。というのも、Blockchain Tableではテーブル作成以降、カラムを追加/変更することができません。その点、Attributeを自由に変更できるJSON型のカラムにしておくことで、将来的な業務要件変化によるデータ項目の追加/変更を吸収できるようにしておくことのメリットが大きいためです。
例:BC_DOCUMENTテーブル上での情報の更新の表現
このテーブル上でobject1
とobject2
、object2
の3つのステートフルオブジェクトのデータを表現すると以下のようなかたちになります。
- object1:revision=1のみ…登録されて以降状態の更新がない。
- object2:revisionが1と2のふたつ…状態が1回更新されており、現在の状態はrevision=2のレコードが示す。
- object3:revisionが1~3の3つ…状態が2回更新されており、現在の状態はrevision=3のレコードが示す。
……と一応説明を書きましたが、一目瞭然ですね。バージョン番号を追加し、複数バージョンを保持することにしても、データそれ自体は特段複雑になるわけではありません。
バージョン番号を付与された複数のレコードから最新の状態を参照するには
一方で、複数バージョンが保持されていることにより、状態を参照するのは若干余分な手間が要求されることになります。
ほとんどの場合、アプリケーションやデータ分析者が最も頻繁に参照したいのは最新の状態です。あるステートフルオブジェクトについての現在の状態が知りたい場合に、いちいち「バージョンが最新のもの」という条件を付してクエリすることになります。これは前述のbc_documentテーブルへのクエリでの例で言うと、WHERE revision = (SELECT MAX(revision) FROM bc_document b WHERE a.document_id = b.document_id)
みたいな条件句をいちいち付与することになります。うーむ、ちょい面倒ですよね。
なので最新の断面を抽出したビューを作ってしまいましょう。
例:最新断面を抽出したビューBC_DOCUMENT_LATEST_VIEWを作成
以下の例のようなビューになります。単一のテーブルを参照するビューの作成なので、特に難しいことはありませんね。
ここでの例;bc_document_latest_view
では、revisionは特に不要なので削っていますが、残してもいいかもしれません。
先程のbc_documentテーブル上のデータのうち、document_idそれぞれについてrevisionが最大のものを抽出するので、このビュー上のデータは以下のようになります。
用が多い最新の状態だけが残ってスッキリしましたね。もちろん(マテリアライズド・ビューではなく)通常のビューなので、裏にあるbc_documentテーブルにレコードが追加された場合には即時にbc_document_latest_viewの参照結果にも反映されます。
どうせなので最新断面のビューに対して更新操作できるようにしたい
Blockchain Tableはテーブル上のデータそのものがイミュータブルなつくりになっています。一方、耐改ざん型データベース(なんらかのかたちで一度書き込まれたデータが削除、変更できないようになっているデータベース)の中には、ステートとジャーナルから構成されており、ジャーナルのほうがイミュータブルで、ステート自体は更新可能なつくりになっているものもあります*1。
アプリケーションから見るとこの「更新可能なステートとイミュータブルなジャーナル」型のほうがとっつきやすい、という意見をいただくことがあります。前述の通り、アプリケーションから頻繁に用があるのは最新の状態、すなわちステートなので、たしかにそのステートが更新可能な(UPDATEできる)ほうがスムーズに使い出せるパターンは多いかも知れません。
というわけで、前述のようにステートの最新断面を参照できるビューを作成できたので、どうせなのでこのビューに対してUPDATEを行えるようにしてみましょう。
例:BC_DOCUMENT_LATEST_VIEWにINSTEAD OFトリガーを設定
Oracle DatabaseにはINSTEAD OFトリガーという種類のデータベース・トリガーがあり、これをビューに仕掛けておくことにより、そのビューへのDML操作をフックして別のテーブル上へのDML操作に振り替えるようなことができます。
ここで以下のような仕様のトリガーtrig_bc_document_latest_view
を作成します。実際のトリガー定義作成のPL/SQLはこの記事の最後にまとめて記載しています。
- ビューへのDELETE操作はエラーにする
- ビューへのINSERT操作は裏のBC_DOCUMENTテーブルへの行INSERTに振り替える(暗黙でrevision=1をセット)
- ビューへのUPDATE操作は:
DELETEとdocument_idのUPDATEを禁じているのは裏のBC_DOCUMENTテーブル側の制約との兼ね合いです*2。
このトリガーにより、アプリケーションから見ると、latest_bc_document_viewを通じてステートの最新断面を参照でき、かつ、ステートの更新もできるようになりました。
そしてその裏では、ステートの更新ごとのスナップショットがイミュータブルな履歴としてBlockchain Tableに蓄積されています。アプリケーションは必要に応じて直接Blockchain Tableをクエリすることで過去の履歴を読むこともできます。
まとめ
この記事ではBlockchain Tableをステートフルオブジェクトの状態のスナップショット履歴の保存に、ビューを状態の最新断面の表現に使うパターンを紹介しました。アプリケーションからの取り回しやすさと、履歴の確実な保存の両立ということでけっこう使いやすいパターンになっているかと思います*3。
Blockchain Tableについての記事では毎回書いてますが、わたしはあまりOracle Databaseちからが高くないのでごくごく基本的なことしか思いつかないし試してみられません。その範囲でもOracle Databaseの多彩、強力な機能を使ってこのように面白い、便利なことができるので、Oracle Databaseに自信ありな方々はBlockchain Tableといろいろ組み合わせて試してみてください。
サンプルスクリプト
-- bc_documentという名前のBlockchain Tableを作成 CREATE BLOCKCHAIN TABLE bc_document ( document_id VARCHAR2(32) NOT NULL, revision NUMBER DEFAULT ON NULL 1, json_content JSON, PRIMARY KEY(document_id, revision) ) NO DROP UNTIL 0 DAYS IDLE NO DELETE UNTIL 16 DAYS AFTER INSERT HASHING USING "SHA2_512" VERSION "v1" ; -- 行をINSERT INSERT INTO bc_document VALUES ('Aランチ', null, '{"price" : "1000", "item1" : "味噌ラーメン", "item2" : "餃子", "item3" : "杏仁豆腐"}' ); INSERT INTO bc_document VALUES ('Bランチ', null, '{"price" : "900", "item1" : "麻婆豆腐", "item2" : "ライス", "item3" : "杏仁豆腐"}' ); INSERT INTO bc_document VALUES ('Cランチ', null, '{"price" : "800", "item1" : "醤油ラーメン", "item2" : "杏仁豆腐"}' ); -- revisionを上げながら行をINSERT INSERT INTO bc_document VALUES ('Bランチ', COALESCE((SELECT MAX(revision)+1 FROM bc_document WHERE document_id = 'Bランチ' GROUP BY document_id),0), '{"price" : "1000", "item1" : "塩ラーメン", "item2" : "半チャーハン", "item3" : "杏仁豆腐"}' ); -- Blockchain Tableから全件SELECT -- JSON型を読み出すときはJSON_SERIALIZEする SELECT document_id, revision, JSON_SERIALIZE(json_content) FROM bc_document ORDER BY document_id, revision; -- ドキュメントごとに最新バージョンのみを抽出するVIEWを作成 CREATE OR REPLACE VIEW bc_document_latest_view AS SELECT document_id, json_content FROM bc_document a WHERE revision = ( SELECT MAX(revision) FROM bc_document b WHERE a.document_id = b.document_id ) ; -- VIEWから全件SELECT -- JSON型を読み出すときはJSON_SERIALIZEする SELECT document_id, JSON_SERIALIZE(json_content) FROM bc_document_latest_view ORDER BY document_id; -- VIEWに対してのDMLを制御するトリガーを作成 CREATE OR REPLACE TRIGGER trig_bc_document_latest_view INSTEAD OF INSERT OR DELETE OR UPDATE ON bc_document_latest_view FOR EACH ROW BEGIN IF INSERTING THEN INSERT INTO bc_document (document_id, revision, json_content) VALUES ( :new.document_id, 1, :new.json_content ); ELSIF DELETING THEN RAISE_APPLICATION_ERROR(-20000, '削除は禁止されています' ); ELSE IF UPDATING( 'document_id' ) THEN RAISE_APPLICATION_ERROR(-20000, 'Document ID列は更新できません' ); ELSIF UPDATING( 'json_content' ) THEN INSERT INTO bc_document (document_id, revision, json_content) VALUES ( :old.document_id, (SELECT MAX(revision)+1 FROM bc_document WHERE document_id = :old.document_id), :new.json_content ); END IF; END IF; END; -- VIEWに対してDELETEを試す→失敗する DELETE FROM bc_document_latest_view WHERE document_id = 'Aランチ'; -- ↑の出力:ORA-20000: 削除は禁止されています ORA-06512: at "ADMIN.TRIG_BC_DOCUMENT_LATEST_VIEW", line 10 ORA-04088: error during execution of trigger 'ADMIN.TRIG_BC_DOCUMENT_LATEST_VIEW' -- VIEWに対してINSERTを試す→成功する INSERT INTO bc_document_latest_view VALUES('Dランチ', '{"price" : "1100", "item1" : "醤油ラーメン", "item2" : "餃子", "item3" : "半チャーハン", "item4" : "杏仁豆腐"}'); -- VIEWに対してdocument_idの更新を試す→失敗する UPDATE bc_document_latest_view SET document_id = '最安ランチ' WHERE document_id = 'Cランチ' ; -- ↑の出力:ORA-20000: Document ID列は更新できません ORA-06512: at "ADMIN.TRIG_BC_DOCUMENT_LATEST_VIEW", line 13 ORA-04088: error during execution of trigger 'ADMIN.TRIG_BC_DOCUMENT_LATEST_VIEW' -- VIEWに対してJSON内容の更新を試す→成功する UPDATE bc_document_latest_view SET json_content = '{"price" : "800", "item1" : "塩ラーメン", "item2" : "マンゴープリン"}' WHERE document_id = 'Cランチ' ;
*1:ところでHyperledger Fabricの台帳構造(ステートを保持するWorld Stateとトランザクション履歴を保持するBlockchain)がまさにこのステート&ジャーナルのかたちですね。
*2:ここではシンプルに禁止していますが、document_idごとに論理削除状態を管理するテーブルを別途作っておいて、論理削除済のものはビューから省く、というような作りにすればここで禁止しているDELETEやdocument_idのUPDATEも論理削除として表現可能だと思います。ちょっと複雑になりすぎる感じもしますが。
*3:こういうパターンってイミュータブルデータモデルの文脈などでわりと頻出なんじゃないかと思うんですがなんか名前ついてたりしますか?思い当たる方いらっしゃったらコメントなどで教えていただけると助かります。