日曜プログラミング

休日趣味でやってるプログラミング関連記事をダラダラと

Datomic transactor に接続できなくなった?

Datomic でトラブルらしきものに初めて遭遇した。

datomic-pro-0.9.4324 の dev プロトコル transactor を自分の PC に立ち上げっ放しで
Emacs cider REPL から接続しようとするといつもならすぐ接続できるのに今日は何故か接続できず
中断すると以下の例外が出た。

CompilerException clojure.lang.ExceptionInfo:
Error communicating with HOST localhost on PORT 4334
{:host "localhost",
 :alt-host nil,
 :port 4334,
 :username "...",
 :password "...",
 :timestamp 1386667907524,
 :version "0.9.4324",
 :encrypt-channel true},
compiling:(form-init9067491083327435996.clj:1:10)

見易いよう適当に改行して :username, :password は暗号化はされてるっぽいが何となく消してはいる。
jack-in で起動した nREPL クライアント/サーバ込みで Emacs を再起動したが変わらず。

transactor 側を再起動させりゃ多分繋がるんだろうけど、
すぐそれをやるのも何だか味気ないのでデフォルトで記録されるログを見てみる。

2013-12-10 18:07:11.813 WARN  default    o.h.c.p.c.i.RemotingConnectionImpl - Connection failure has been detected: 
Did not receive data from /127.0.0.1:59797.
It is likely the client has exited or crashed without closing its connection, 
or the network between the server and client has failed.
You also might have configured connection-ttl and client-failure-check-period incorrectly. 
Please check user manual for more information. The connection will now be closed. [code=3]

エラい細かく書かれてますなw

どうも前の接続が閉じられる前に強制終了させたのがマズかったのかな?確かにタスクマネージャで Emacs 経由で起動した java.exe を終わらせた気はする。

transactor って起動しとかなくても動く?

これに対応してるときにふと思いついたのだが、

Datomic Development Resources

アーキテクチャ見てると実は transactor ってなくても動くんじゃね?とかと思って
今度はワザと transactor を終了させて Emacs cider REPL から繋いでみようとしたが

(def con (d/connect uri))
; => CompilerException java.util.concurrent.ExecutionException: 
;    org.h2.jdbc.JdbcSQLException: データベースはすでに閉じられています 
;    (VM終了時の自動データベースクローズを無効にするためには、db URLに ";DB_CLOSE_ON_EXIT=FALSE" を追加してください)
;    Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL)
;     [90121-171], compiling:(form-init9067491083327435996.clj:1:10) 

やっぱダメすか。

Datomic Console を試す

以前ちょいと紹介だけしたDatomic Console。細かい紹介は例によって公式に譲る。インストールについても README を読めば特に何も困らなかったので割愛し、感想だけ。

  • 定義したスキーマがツリー状に見られるのが良い
    • ツリーの親は :foo/bar だったら :foo/ だけになって登録時に見る生 EDN より見易くなる
    • スキーマ定義時に :doc も入れておけばコメントも見られる
  • パッと見ダサいと思ってた :where 条件の追加 UI が意外にも Datalog とマッチしてる
    • 「エンティティ」の「属性」が「値」であるもの、と言うのを基本概念にして積み上げる言う方式と合う(残り 2 項目が「トランザクション」と何だっけ、忘れた)
    • 定義済みスキーマや入力済みのものの補完候補も出る
    • 試したクエリを一時的に名前を付けて保存も可能だが、どうも Console を立ち上げている間だけっぽいのでアプリに組み込む時は忘れずに移す事
  • スキーマ定義やデータの追加には未対応
    • そこは諦めてファイルに spit するなり変数に def するなりして transact しましょう

結論としては、Datomic 用のクエリエディタ & 実行環境として簡易的ながらも十分な機能を備えてるんじゃないだろうか。別ダウンロードでインストールに一手間必要らしいが free 版でも使用できるようなので実際に開発するとなれば使わない手はないと思う。

datomic でハマった事いくつか

datomic でまだまだ遊んでいる所なのだがハマった事を順次書いていく。

DB は常に最新を見ましょう

結論から言うと DB 指定する所では過去を振り返る必要が出てくる時以外は常に (db conn) で最新の DB を指定すべきと言うお馬鹿なオチ。

話を単純化するためにここに気づいた経緯を mem プロトコルで attribute 一つ定義した simple-db で大体の流れを再現する。

(def uri "datomic:mem://simple-db") ; URI 指定

(require '[datomic.api :as d])
(d/create-database uri)         ; DB 作成
(def cn (d/connect uri))        ; 接続
(def db (d/db cn))              ; データ・ソース

; スキーマ定義
(def s-tx
  {:db/id #db/id[:db.part/db]
   :db/ident :foo/bar
   :db/valueType :db.type/string
   :db/cardinality :db.cardinality/one
   :db.install/_attribute :db.part/db})
; => #'user/s-tx

; 定義登録
@(d/transact cn s-tx)
; => {:db-before datomic.db.Db@b0a677a7, :db-after datomic.db.Db@f49877b5, :tx-data ...}

; 定義が入ったか確認。
; 指定した DB に定義があればデータ無しの空のSetが返ってくるがエラー
(d/q '[:find ?e :where [?e :foo/bar]] db)
; => IllegalArgumentExceptionInfo :db.error/not-an-entity Unable to resolve entity: :foo/bar  datomic.error/arg (error.clj:55)


; 上を無視してデータ登録
@(d/transact cn [[:db/add #db/id[:db.part/user] :foo/bar "baz"]])
; => {:db-before datomic.db.Db@f49877b5, :db-after datomic.db.Db@f3a1ba2f, :tx-data ...}

; データがあるか確認するがやはりエラー
(d/q '[:find ?e :where [?e :foo/bar]] db)
; => IllegalArgumentExceptionInfo :db.error/not-an-entity Unable to resolve entity: :foo/bar  datomic.error/arg (error.clj:55)


; ・・・あ、DB 指定が最初のままだった
(d/q '[:find ?e ?v :where [?e :foo/bar ?v]] (d/db cn))
; => #{[17592186045418 "baz"]}

と言うオチ。

属性値を更新する時はまずエンティティ ID を取る

さて、:foo/bar の値を更新したい時。

こうすると、

; :foo/bar を更新したくこうしてみたが、
@(d/transact cn [[:db/add #db/id[:db.part/user] :foo/bar "baz"]])
=> {:db-before datomic.db.Db@f3a1ba2f, :db-after datomic.db.Db@f2e0d6df, :tx-data ...}
(d/q '[:find ?e ?v :where [?e :foo/bar ?v]] (d/db cn))
=> #{[17592186045420 "baz"] [17592186045418 "baz"]}

このようにエンティティは異なるものの値が同じ attribute を持つものが複数出る。
これはすぐ分かるかもしれないが、:db/add で指定するエンティティ ID が #db/id[:db.part/user] と新たに ID 発行するよう指定しているのが原因。この現象を回避と言うのも語弊があり、datomic 本来の動作なのだ。

ある属性の値を変えたい場合はまずエンティティ ID を取得する。

; まず重複データを削除しておく
@(d/transact cn [[:db.fn/retractEntity 17592186045418]])
(d/q '[:find ?e ?v :where [?e :foo/bar ?v]] (d/db cn)) ; 確認
; => #{[17592186045420 "baz"]}

; エンティティ ID を取る
(def e-id (ffirst (d/q '[:find ?e :where [?e :foo/bar]] (d/db cn))))
@(d/transact cn [[:db/add e-id :foo/bar "fizz"]])
(d/q '[:find ?e ?v :where [?e :foo/bar ?v]] (d/db cn))
=> #{[17592186045420 "fizz"]}

もしくはまとめて登録する場合 attribute のスキーマ定義時に(datomicでは略して A V と呼んでたりしてる)ユニーク指定をしてやる方法もある。

;; 一旦 DB 丸ごと消して再度作る
(d/delete-database uri)
(d/create-database uri)
(def cn (d/connect uri))        ; 接続

; スキーマ定義・登録。今度は :db/unique を追加。指定値は今の所 :db.unique/identity のみ。
(def s-tx
  [{:db/id #db/id[:db.part/db]
    :db/ident :foo/bar
    :db/valueType :db.type/string
    :db/unique :db.unique/identity
    :db/cardinality :db.cardinality/one
    :db.install/_attribute :db.part/db}])
@(d/transact cn s-tx)

; 別エンティティIDで同じ属性に同じ値を 2 回追加
@(d/transact cn [[:db/add #db/id[:db.part/user] :foo/bar "baz"]])
@(d/transact cn [[:db/add #db/id[:db.part/user] :foo/bar "baz"]])

; unique 指定された属性に関しては同じ値の追加は許さない
(d/q '[:find ?e ?v :where [?e :foo/bar ?v]] (d/db cn))
; => #{[17592186045418 "baz"]}

確かドキュメントに SQL で言う DISTINCT 指定もできたとあった気がしたが試してない。SQL でもそうだが、単純な重複削除は JOIN させた時などに問題を見えにくくする事があるのであまり使いたくない。

これ敢えて別エンティティに同一属性を敢えて2つ持たせてやるような場面あるかな。。。ないか。
一つの属性に複数の値を持たせたい場合ならそれは :db/cardinality に many を指定してやるべきだと思う。

datomic ブログを読む - クエリ駆け足ツアー

チュートリアルを終えたものの、まだどうも慣れない部分が多いのでもう少しサンプル的なものを試したいと探してたらブログにより単純なスキーマでのクエリの使い方紹介記事があった。

Datomic: A Whirlwind Tour of Datomic Query

コードの入手と準備

github の下記サイトを git clone なり zip ダウンロードなりお好きなように。
https://github.com/datomic/day-of-datomic

入手したものは leiningen プロジェクトなので適当な場所にフォルダを置いてそこへ移動。
lein deps & emacs cider で cider-jack-in し、sample\query_tour.clj を開けば準備は完了。

後は query_tour.clj にあるコードを repl 上で試す。

スキーマ

今回のスキーマはこんな感じだそうで。

http://4.bp.blogspot.com/-q45Fo2EBeEE/UZUF5FVHWNI/AAAAAAAAAA0/8BzCsuLos58/s1600/social-news-schema.png

以降のクエリとこのスキーマの画像とを見比べてみると良いかも。

接続(Get Connected)

(require
  '[datomic.api :as d]
  '[datomic.samples.news :as news])

  (def uri "datomic:mem://news")
  (def conn (news/setup-sample-db-1 uri))

news/setup-sample-db-1 でスキーマ定義とデータ登録を済ませて接続を返している以外は特に解説する事なし。

最新の DB 取得(Get Database Value)

(def db (d/db conn))

前のチュートリアルでも触れたが、datomic は時間が組み込みである関係上クエリを実行する際は引数にどの時点の DBにするのかを引数で必ず指定する必要がある。

db 関数は引数に DB 接続を取り、最新の DB の値を返す。それを変数 db に入れておく。

最初のクエリ(A first query)

少しクドいかもしれないけど慣れるまではクエリの意味を逐一コメントに書いていきたいと思う。
後ブログでの書き方に倣って、repl での実行結果は => で表記する事にする。

; :user/email 属性を持つエンティティ ?e のエンティティそのものを探す
(d/q '[:find ?e
       :where [?e :user/email]]
     db)
=> #{[17592186045424] [17592186045425]}

数字で見えてるのはエンティティ ID。

単純な条件指定(A simple join)

; :user/email が "editor@example.com" のエンティティ ?e のエンティティそのものを探す
(d/q '[:find ?e
       :in $ ?email
       :where [?e :user/email ?email]]
     db
     "editor@example.com")
=> #{[17592186045425]}

元には join とあるが、SQL の感覚で言えば特定の条件指定とした方が意味は近いかもと思い敢えてタイトルはこうしてみた。

Oracle SQL で近い意味合いのクエリを無理やり書くとすればこうかな。

-- ? がバインド変数で呼び出し時に "editor@example.com" にする
SELECT ROWID
  FROM user
 WHERE email = ?

ROWID は擬似的なもので datomic のエンティティ ID とは違うが。

結合(A database join)

; email が "editor@example.com" であるユーザのコメントを探す
(d/q '[:find ?comment
       :in $ ?email
       :where [?user :user/email ?email]
       [?comment :comment/author ?user]]
     db
     "editor@example.com")
=> #{[17592186045442]
     [17592186045441]
     ...}

スキーマを見返すと、:comment/author は user を参照しており、ここで暗黙的に結合されている事になる。結合は :comment/author の参照が特定のエンティティ全体を指すので、SQL の JOIN で書こうと思っても書けない。SQL での JOIN で指定できるのは特定のフィールド同士だけ。(datomic での属性が近いとは言える)。

もし無理やり書くとすればこんな感じになるかもだが、当然 JOIN の条件でテーブルの指定はできず、テーブル作っても動く環境はない、はず。

SELECT c.comment
  FROM comment c
       INNER JOIN user u 
          ON u.user = c  -- テーブルの指定はできない
             AND u.email = "editor@example.com"

また、datomic のクエリをいきなりパッと見てもすぐ意味が分かるものではないような気もするので、内部的な処理と合致するかはさておき、このクエリがどう評価されるのかをもう少し細かく書いた方がいいかもしれない。

まず、外から与えられた変数である db と "editor@example.com" を :in へバインド。

[:find ?comment
 :in db "editor@example.com"
 :where [?user :user/email ?email]
 [?comment :comment/author ?user]]

?email は "editor@example.com" なので :where 句にあるのも同様に展開。

[:find ?comment
 :in db "editor@example.com"
 :where [?user :user/email "editor@example.com"]
 [?comment :comment/author ?user]]

:in でバインドされたものは他に展開するところがないので便宜上削除し、その結果のクエリを読んでみる。

[:find ?comment                                  ; 3. コメントを探す
 :where [?user :user/email "editor@example.com"] ; 1. "editor@example.com" のユーザで
 [?comment :comment/author ?user]]               ; 2. かつコメントがそのユーザである

何となく :in -> :where -> :find の順で読んだ方がいいのかも。

集計(Aggregates)

そういやチュートリアルで見なかった集計。サンプルはこう。

; "editor@example.com" がメールアドレスであるユーザのコメント数
(d/q '[:find (count ?comment)
       :in $ ?email
       :where [?user :user/email ?email]
       [?comment :comment/author ?user]]
     db
     "editor@example.com")
[[20]]

これは特にどうと言う事もない。そういや他に集計関数って何があんだろ。

さらなる結合(More joins)

; メールアドレス付きでコメントが付けられてるコメントを探す(自信なし)
(d/q '[:find (count ?comment)
       :where
       [?comment :comment/author]
       [?commentable :comments ?comment]
       [?commentable :user/email]]
     db)
[]

説明じゃコメントに対してのコメントは付けられないからとあるがそれが確認できるのは次?

スキーマ情報を取得する(Schema-aware joins)

一つ前のクエリでコメントに対してコメントは不可と言う事は分かった。では、コメントは何に対してだと付けられるのか?RDBMS だとこのようなメタ情報の取得は各ベンダ固有の取得方法になるのだが、datomic だと以下のようなクエリで取得できる。

; :comments を持つエンティティの属性名を取得する
(d/q '[:find ?attr-name               ; 4. 属性名を探す
       :where
       [?ref :comments]               ; 1. :comments がある実体 ?ref で、
       [?ref ?attr]                   ; 2. その属性の、
       [?attr :db/ident ?attr-name]]  ; 3. 属性名が ?attr-name な
     db)
=> #{[:story/title] [:comment/body] [:story/url] [:comment/author] [:comments]}

:where 句に来る各条件の構成は、

[entity attribute value transaction]

となり、これまでのクエリだと entity には ? で続く変数、attribute には :foo/bar のような定数、value は省略するか変数であるものばかりだった*1。ここでは ?attr で条件を繋いでいるのがポイントだと思う。また、この結果には一つ前のクエリで指定してた :user/email は無いのでコメントが付けられない事が分かる。

エンティティ(Entities)

エンティティを直接扱うクエリを幾つかまとめてドンと。

; "editor@example.com" がメールアドレスのエンティティ ID を取得
; ffirst かましてるのはクエリが返すのは [[id]] な為
(def editor-id (->> (d/q '[:find ?e
                           :in $ ?email
                           :where [?e :user/email ?email]]
                        db
                        "editor@example.com")
                  ffirst))
=> #'user/editor-id

(def editor (d/entity db editor-id))
=> #'user/editor

; entity は lazy なそうなんだが良く分からん
editor
=> {:db/id 17592186045425}

; こんな感じで Clojure の Map ライクに扱う事も可能
(:user/firstName editor)
"Edward"

; editor の属性と値全部返す
(d/touch editor)
=> {:user/lastName "Itor", :user/email "editor@example.com", :user/firstName "Edward", :db/id 17592186045425}

ブログ記事にはないおまけ。
最後のを使えば datomic で SELECT * FROM table みたいなのできそうな気がしたので試してみた。

(def users (->> (d/q '[:find ?e
                       :where [?e :user/email]]
                     db)
                (map first)))
=> #'user/users

(map #(d/touch (d/entity db %)) users)
=> ({:user/firstName "Stu", :user/lastName "Halloway", :user/email "stuarthalloway@datomic.com", :db/id 17592186045424} 
    {:user/firstName "Edward", :user/lastName "Itor", :user/email "editor@example.com", :db/id 17592186045425})

ただ、datomic のエンティティ = RDBMS のテーブルの 1 レコードでもないし、上記のクエリと処理も意味合いとしては「:user/email を持つエンティティを全て取得し、d/touch で各エンティティの全ての属性を取得する」となる。何と言うか、RDBMS で言うテーブルを持っている訳ではない。

エンティティって ORM なの?(Are Entities ORM?)

ここは訳もサンプルの理解もかなり自信がなかったのですみませんが原文参照と言う事で。

サンプルだけ載せときます。

; 逆方向へのリレーションシップ。
; 意味合いとしてはこの編集者に付いたユーザを返す?
(-> editor :comment/_author)
=> #{{:db/id 17592186045445} 
     {:db/id 17592186045476} 
     ...}

; 更にそのユーザのコメントを返す?
(->> editor :comment/_author (mapcat :comments))
=> ({:db/id 17592186045464}
    {:db/id 17592186045463}
    ...)

時間旅行(Time travel)

過去のを取得する場合、DB の特定のポイントの取得が必要になる。特定のポイントを取得する方法として、

  1. datom を生成するトランザクションエンティティ tx を取得
  2. トランザクション相対的な時間 t を取得
  3. トランザクションの :db/txInstant で絶対的な時間を取得

がある。

トランザクションエンティティ tx の取得

実際的に扱うのは数値で表されたトランザクション ID で、クエリで以下の様な感じで取得できる。

; 現在の editor-id で示すトランザクション ID を取得する
(def txid (->> (d/q '[:find ?tx
                      :in $ ?e
                      :where [?e :user/firstName _ ?tx]]
                    db
                    editor-id)
                 ffirst))

=> #'user/txid

トランザクション相対的な時間 t を取得

結果の値の意味も使い所も今ひとつ良く分からない。

(d/tx->t txid)
=> 1042

トランザクションの :db/txInstant で絶対的な時間を取得

(-> (d/entity db txid)
    :db/txInstant)
=> #inst "2013-11-09T11:36:52.898-00:00"

時間がブログのと違うが大丈夫かな。

過去を見る

; 一つ前の時間軸の DB を取得
(def older-db (d/as-of db (dec txid)))
=> #'user/older-db

; で、その時間の時の editor-id に入ってる人の名前
(:user/firstName (d/entity older-db editor-id))
=> "Ed"

; 今の DB と比較すると違うのが分かる
(:user/firstName (d/entity db editor-id))
=> "Edward"

1つ目の as-of で今のトランザクション ID より 1 デクリメントした ID を指定して過去の DB を見るようにしてる所がポイント。

時間軸横断(Audit)

こんな感じ。

; DB 自体の履歴を取る
(def hist (d/history db))
=> #'user/hist

; ↑の DB をデータ・ソースとして editor-id の人の :user/firstName 一覧を取る
(->> (d/q '[:find ?tx ?v ?op
            :in $ ?e ?attr
            :where [?e ?attr ?v ?tx ?op]]
          hist
          editor-id
          :user/firstName)
     (sort-by first))
=> ([13194139534319 "Ed" true]
    [13194139534323 "Ed" false]
    [13194139534323 "Edward" true]
    [13194139534352 "Ed" true]
    [13194139534352 "Edward" false]
    [13194139534354 "Edward" true]
    [13194139534354 "Ed" false])

d/history で指定した DB 以前の全体の履歴を取る。「以前」としてるのは as-of や since もサポートしてそうな事を API ドキュメントに書いてあるのを見つけたからだが動作未確認。

で、全体履歴含む DB を指定して editor-id の名前の一覧を取得。?op には true/false が入り、true は add or assert された有効なデータ、false は retract された無効なデータを示す。通常のクエリの結果でも出ているが、最新のトランザクションで有効な名前は "Edward" である事がここでも分かる。

全てはデータ(Everything is Data)

これはちょっと面白くて、クエリはデータベースを用意せずとも実行できるというサンプル。

(d/q '[:find ?e
       :where [?e :user/email]]
     [[1 :user/email "jdoe@example.com"]
      [1 :user/firstName "John"]
      [2 :user/email "jane@example.com"]])
=> #{[1] [2]}

クエリエンジンが扱えるデータ構造を渡してやればちゃんと処理できるのだ。

今日はここまで。

*1:transaction については後述

datomic を試す(6) - データ操作、データ定義再び

datomic チュートリアルについては今回の記事で最終回。

前回は datomic の目玉の一つである時間の考え方について見てきた。
datomic を試す(5) - 時と共に - 日曜プログラミング

今回の記事で公式チュートリアルで該当する原文項目は以下。

  • Manipulating data
    • Transactions
    • Temporary ids
    • Adding, updating, and retracting data
  • Schema and seed data, revisited

原文にある通り、データ操作についてと、定義済みファイルに頼っていたデータ定義について改めて見ていく。

データ操作(Manipulating data)

トランザクション(Transactions)

トランザクションのデータ構造は単純なリスト or マップを要素とするリストらしい。

擬似コードでの例は以下のようなもの。

[:db/add entity-id attribute value]     ; 1 何かしらのエンティティの属性値を追加
[:db/retract entity-id attribute value] ; 2 何かしらのエンティティの属性値を差し戻し
[data-fn args*]                         ; 3 関数呼び出し

関数呼び出しって何?と言われると、例えば組み込みで retractEntity があり、これはエンティテ
ィ全体を差し戻す関数。

[:db.fn/retractEntity entity-id]

あるエンティティの属性値をまとめて変更する場合には Map 構造が使われる。

{ :db/id entity-id
  attribute value
  attribute value
  ... }

最初の :db/id にエンティティ ID を必須で指定する必要がある。後は属性名と値のペアを続けていく。

これは内部的にはリストに変換されるらしいが使う際に意識する事あるのかな。

テンポラリ ID(Temporary ids)

datomic の transaction においてエンティティ ID 指定は必須であり、実際更新・差し戻しをやる場合には次に示す方法でテンポラリ ID を発行する形になる。

その方法は 2 つあって、一つはリテラルライクに以下のように指定する方法。

#db/id[partition-name value*]

もう一つは Peer.tempid*1メソッドを実行する方法で、必要な引数はリテラルライク指定時と変わらず partition-name と任意で負数との事。

さて、自分にとってはパーティションって何よ、ってのが疑問だったが、この後を読むと何となく理解できた。どうもエンティティ ID を発行する単位のようなものっぽく、パーティション毎にキーワードの形で識別子が付けられている。

組み込みのパーティションもあり、以下表のものがある。

Partition 用途
:db.part/db システムパーティション、属性名や内部的な用途でのみ使われる
:db.part/tx transaction パーティション、 transaction エンティティのみに使われる
:db.part/user ユーザパーティション、 アプリケーション向け

この表を見るに、datomic システムのドメイン別にエンティティ ID は分けられているみたい。

通常は :db.part/user のみ使うべきだが、パーティション自体もエンティティである為、以下のように独自のパーティションを定義する事も可能。

[{:db/id #db/id[:db.part/db],
  :db/ident :communities,
  :db.install/_partition :db.part/db}]

データ追加・更新・差し戻し(Adding, updating, and retracting data)

データ追加

書式としてはこうすれば良いらしい。

[{:db/id #db/id[:db.part/user] :community/name "Easton"}]

実際に追加する前に :community/name が "Easton"のエンティティが本当にないのかが不安なので試してみる。

> (d/q '[:find ?e :where [?e :community/name "Easton"]] (d/db cn))
#{}

ないか。

では実際に追加・確認してみる。チュートリアルのこの項目では改めての説明がなく一瞬戸惑ったが、データの追加・更新を行うには transact を呼び出して DB 接続オブジェクトと追加するデータを指定する。

> (d/transact cn [{:db/id #db/id[:db.part/user] :community/name "Easton"}])
#<promise$settable_future$reify__5411@9053ac: {:db-before datomic.db.Db@1cc08267, :db-after datomic.db.Db@766fc0fe, :tx-data [#Datum{:e 13194139534685 :a 50 :v #inst "2013-11-07T00:06:33.427-00:00" :tx 13194139534685 :added true} #Datum{:e 17592186045790 :a 62 :v "Easton" :tx 13194139534685 :added true}], :tempids {-9223350046623220289 17592186045790}}>
> (d/q '[:find ?n :where [(fulltext $ :community/name "Easton") [[?e ?n]]]] (d/db cn))
#{["Easton"]}
データ更新

データの新規追加については前述したようにテンポラリ ID を発行する形となっていたが、既存のデータ更新に関してはテンポラリ ID に対してエンティティに付与されている ID を指定する事になる。

その為には更新をかけたいエンティティに対しての ID 取得が必要になる。
クエリとしては以下。

[:find ?belltown_id :where [?belltown_id :community/name "belltown"]]

まあここまで読み進めてくると想像はつくかも。

では実際に試してみる。ここでは belltown の :community/category を変更したいので変更前のカテゴリを確認しておく*2

> (d/q '[:find ?cat
         :where
          [?c :community/name "belltown"]
          [?c :community/category ?cat]]
    (d/db cn))
#{["events"] ["news"]}

んでコミュニティ名が "belltown" なエンティティ ID を取得。

> (def belltown-id (ffirst (d/q '[:find ?id :where [?id :community/name "belltown"]] (d/db cn))))
#'datomic-tut.core/belltown-id

取得したエンティティ ID を使って更新し、さっきと同じクエリで確認。

> (d/transact cn [{:db/id belltown-id  :community/category "free stuff"}])
#<promise$settable_future$reify__5411@12a0078: {:db-before datomic.db.Db@766fc0fe, :db-after datomic.db.Db@52ea7922, :tx-data [#Datum{:e 13194139534687 :a 50 :v #inst "2013-11-07T00:31:18.898-00:00" :tx 13194139534687 :added true} #Datum{:e 17592186045476 :a 65 :v "free stuff" :tx 13194139534687 :added true}], :tempids {}}>
> (d/q '[:find ?cat
         :where
         [?c :community/name "belltown"]
         [?c :community/category ?cat]]
    (d/db cn))
#{["events"] ["free stuff"] ["news"]}

belltown のカテゴリが追加されたのが分かる。

データ取り消し(retract)

最後にデータ取り消しの方法について。datomic では仕組みとして追加オンリーのシステムなので、DELETE などそのまま削除とは呼ばないようで、retract と表現してる。最初差し戻しとか言ってたが取り消しの方がまだ通じやすいかな。

やり方は更新と同じく実際のエンティティ ID を指定する、のだが内側が [[]] でなく {} なのが少し気になる。

[[:db/retract belltown_id :community/category "free stuff"]]

違うのは最初が :db/id でなく :db/retract になるというところだけ。

ではさっきカテゴリを更新した "free stuff" を取り消してみる。

> (d/transact cn [[:db/retract belltown-id  :community/category "free stuff"]])
#<promise$settable_future$reify__5411@a19599: {:db-before datomic.db.Db@52ea7922, :db-after datomic.db.Db@b057a2f0, :tx-data [#Datum{:e 13194139534688 :a 50 :v #inst "2013-11-07T00:41:44.727-00:00" :tx 13194139534688 :added true} #Datum{:e 17592186045476 :a 65 :v "free stuff" :tx 13194139534688 :added false}], :tempids {}}>
datomic-tut.core> (d/q '[:find ?cat
			 :where
			 [?c :community/name "belltown"]
			 [?c :community/category ?cat]]
		       (d/db cn))
#{["events"] ["news"]}

ちゃんと消えている。と言うよりは現時点の最新状況では見えなくなっていると言う感じで実データを消したわけではなく削除フラグが立てられたようなものと言うイメージ。過去を遡れば見ることは出来るんだろうが、ここでは改めての確認はしない。

また、これも未確認だが、retract 時に指定した属性の値が正確でない場合、その transaction は強制終了する。その為、transaction は改めて取得する必要があるとのこと。:db.fn/retractEntity を使って Entity 全体を取り消す場合は考慮する必要はないようだ。

データ定義・データ再び(Schema and seed data, revisited)

ここまで来ると確かにデータ定義も読めるようになってくる。恐らくこれを読んで試してる方は手元に読み込んだ時のファイルがあると思われるのでここで改めての掲載はしない。

ここで説明されている注意事項についていくつか。

  • 属性定義追加時に必要になるエンティティ ID 発行パーティションには :db.part/db を使う。
  • リテラルライクでの #db/id 指定によるデータ追加更新は文字列やファイルからの読込向けの為のもので、transaction をプログラマブルに行いたい場合は datomic.Peer.tempid を使うようにとの事。
  • 属性定義追加時には :db.install/_attribute も入れておく事。値には :db/id で指定したパーティションと同じものを指定する。
  • :region/ne などのある意味定数として扱うものは :db.part/user パーティションで ID 発行して追加している。

また、その他気づいたところと言うか気になるところと言うか。

  • 定数として定義したものを参照する属性 :community/type や :district/region などの値タイプは :db.type/ref となっているものの、定義上で明確に紐付いているわけではないのが少し気になる
  • :db.type/ref な属性の参照先の仮とも言うエンティティ ID が読み込んだファイルでは負数でリンクしていて、transactで確定登録された場合は適切なエンティティ ID になる、と言う所までは何となく分かるんだが、もうちょっと良い方法ないのかな

私的まとめ

元の公式チュートリアルは良く出来てると思う。特にクエリ中心に説明してるのは少なくとも日本語リソースではあまり見かけないので、ちゃんとした翻訳じゃないにせよこの記事を起こす動機になった。RDBMS 相手にした開発だと SQL ゴリゴリ書く場面も多いしやっぱりクエリ言語を把握するのは大事だとも思っているので。

ただ、チュートリアルだけでは RDBMS+SQL より便利になってるかどうかは正直自分はまだ良く分からない。新規プロジェクトならばパフォーマンスの良さそうなキーバリューストアを探してそいつをストレージとして組み合わせて使うのはいいのかも。自分としてはもっと使い込んでみないとまだ何とも。

*1:動作未確認の為ここではチュートリアルままの Java のメソッドの方を挙げた。とは言え Clojure API は多分 tempid になると思われる。

*2:せっかくの datomic なんだから時間で遡る方法取れば良いのかもしれないけどまだ慣れてない

datomic を試す(5) - 時と共に

前回はパラメータクエリ、クエリ内関数、全文検索、ルールの使用などを見てきた。
datomic チュートリアルを試す(4) - アドバンスド・クエリー - 日曜プログラミング

今回の記事で公式チュートリアルで該当する原文項目は以下。

  • Working with time
    • Time is built in
    • Revisiting the past
    • Imagining the future

datomic の紹介関係で良く見る

  • 追加のみのデータベースで時間は組み込みである

と言うのを実際に扱うにはどうするのかチュートリアルでもようやく出てきた。

サブタイトル適当訳してたら妙にキザっぽくなってしまったのはご愛嬌。

時間は組み込み(Time is built in)

これは良く宣伝される所でここでの細かい紹介は外部サイトに譲るが、自分の理解ではDB 全体で変化があったら自動的にマーキングして時系列的に持っているイメージ。多分内部的にはまるごとコピーではなく変化した差分だけ持っているような感じなんだろう。更新・消去の管理は別プロセスで動いている transactor で一手に引き受けてる。

で、今接続している db の更新日時一覧を取得するパラメータクエリ実行サンプル。

> (d/q '[:find ?when
         :where
         [?tx :db/txInstant ?when]]
       (d/db cn))
#{[#inst "2013-10-25T05:34:20.048-00:00"]
  [#inst "2013-10-25T05:47:10.216-00:00"]
  [#inst "1970-01-01T00:00:00.000-00:00"]}

DB 自身が持つ :db/txInstant 属性を見ればいいようだ。最初の 1970 年のはスタートポイントとして既に入ってるのかな?また、Clojurian だともう常識かもしれないが、:db/txInstant で返ってきた #inst に続く値は java.util.Date 型の Clojure 表記。

チュートリアルの最初の方でやった一番新しい時間が実データの読込を、その一つ前の時間がスキーマ読込を実施した時間になる。

過去を訪ねる(Revisiting the past)

一つ前の項目で DB の更新日時一覧を取得する方法は分かったので、今度は特定の日時でのデータが本当に取得できるのかを確認してみる。

現段階でのチュートリアルにおけるデータ更新タイミングは先にも書いた通りスキーマ定義した時とその後にデータをまるっと追加した時の 2 つ。スキーマ定義した段階に遡って単純なクエリを実行すれば何もデータが返って来ない事が予想される。

確かめてみる。

> (let [tx-dates (->> (d/q '[:find ?when :where [?tx :db/txInstant ?when]] (d/db cn))
                      (map #(% 0))
                      sort)
        query '[:find ?c :where [?c :community/name]]]
    (map #(count (d/q query (d/as-of (d/db cn) %))) tx-dates))
(0 0 150)

クエリで返ってくるのは Java の HashSet で順序の保証はないため、sort をかます。

d/q で通常渡されている二つ目の引数が (d/db cn) でなく (d/as-of (d/db cn) %) なのに注目。
as-of の1つ目に DB を指定し、2 つめに transaction の特定のポイントとなるものを指定。ここでは最初に取得した日時を指定しているが、API リファレンス によるとトランザクション ID やトランザクション No を指定できるらしい。それをどうやって取るのかはまだ良く分からん。

返って来た結果は各更新日時(原文では transaction point とか何かそんな表現してる)段階でのクエリ結果件数のシーケンス。先頭は 1970 年とかなってて多分無視して良いもので、2 つ目がスキーマ定義時のデータ件数、最後がデータ登録後のデータ件数となり、この結果からスキーマ定義時にはデータが一切入ってない事が分かる。

さて、DB 全体で履歴を持つとなると差分だけとは言えデータの増加量が心配になる。デフォルトではどうやら履歴を取るようにしているらしいが、属性単位で定義時に :db/noHistory true を追加してやればその属性については履歴を取らないようにする事も可能。

未来を思い描く(Imagining the future)

leiningen プロジェクト作成時に移動した 3 ファイルの内、まだ使っていなかった seattle-data1.dtm がある。ここでそいつを読み込んでやる。

; read-dtm は自分が独自に定義したヘルパー関数。前の記事参照。
(read-dtm "resources/seattle-data1.dtm")

ここで自分が失敗したんだが、read-dtm では内部的に transact を呼んでいて、これはどうも SQL で言う COMMIT に相当するものみたい。仮に登録してどうなるのか確認する為には transact の代わりに with を使ってみるといいよ、と言う趣旨の説明をしてるっぽい。

ちょっとチュートリアルの流れと異なるが、自分が確認した方法をここに載せる。

> (let [tx-dates (->> (d/q '[:find ?when :where [?tx :db/txInstant ?when]] (d/db cn))
                      (map #(% 0))
                      sort)
        query '[:find ?c :where [?c :community/name]]]
    (map #(vector % (count (d/q query (d/since (d/db cn) %)))) tx-dates))
([#inst "1970-01-01T00:00:00.000-00:00" 258]
 [#inst "2013-10-25T05:34:20.048-00:00" 258]
 [#inst "2013-10-25T05:47:10.216-00:00" 108]
 [#inst "2013-11-06T05:26:55.521-00:00" 0])

さっきも見たようなコードだが、as-of の代わりに since によるクエリ結果の件数を返している。これは結果を見るに、指定したポイントからの差分データのみを取るようなイメージみたい。なので、最新の日時では当然 0 件で、その一つ前は seattle-data1.dtm を読み込む前となるからその増えたデータ数である 108 が返ってきている。

ここでこの項目終わり。

ん~、どちらかと言うと時系列間を横断してデータを取る例を見たかったんだが何となく想像はつくので次へ進む事にする。

datomic チュートリアルを試す(4) - アドバンスド・クエリー

前回、SQL で言うと SELECT, WHERE, JOIN の基本的な使い方についてやったような感じかな。記事はこちら。
datomic チュートリアルを試す(3) - 実体の属性値をクエリで取る - 日曜プログラミング

今回の記事で公式チュートリアルで該当する原文項目は以下。

  • Advanced queries
    • Parameterizing queries
    • Invoking functions in queries
    • Querying with fulltext search
    • Querying with rules

今回は Advanced queries らしいです。
Advanced とかの響きがカッコいいので今回は直訳しました。

パラメータクエリ(Parameterizing queries)

前回書いたクエリのクエリ部分のみ再掲。

; コミュニティのタイプが twitter なコミュニティ名を取得する
[:find ?n
 :where
 [?c :community/name ?n]
 [?c :community/type :community.type/twitter]]

このクエリをパラメータクエリにするとこんな感じ。

(d/q '[:find ?n
       :in $ ?t
       :where
       [?c :community/name ?n]
       [?c :community/type ?t]]
     (d/db cn)
     :community.type/twitter)

違いは :in の部分。$ が外から与えられるパラメータを表してて、この例だと与えられたものがクエリ内の ?t にバインドされるイメージ。
$ が外から与えられるもののプレフィックスみたいなもんで $ の後に任意で名前をつけることも可能。

これでクエリをこんな感じで関数化する事も可能に。

; t で指定されたタイプのコミュニティ名を返す
(defn find-by-type [t]
  (d/q '[:find ?n
         :in $ ?t
         :where
         [?c :community/name ?n]
         [?c :community/type ?t]]
       (d/db cn) t))

使ってみる。最初に :community.type/twitter まんま指定してクエリ実行してた時と同じ結果になる。

> (find-by-type :community.type/twitter)
#{["Discover SLU"]
  ["Fremont Universe"]
  ["Columbia Citizens"]
  ["Magnolia Voice"]
  ["Maple Leaf Life"]
  ["MyWallingford"]}

twitterfacebook いずれかのコミュニティ名を取得したい場合、:in が少し変わる。

; t で指定されたタイプのコミュニティ名を返す(複数版)
(defn find-by-types [t]
  (d/q '[:find ?n ?t
         :in $ [?t ...]
         :where
         [?c :community/name ?n]
         [?c :community/type ?t]]
       (d/db cn) t))

実行。渡すのは属性のベクタなのに注意。

> (find-by-types [:community.type/twitter :community.type/facebook-page])
#{["Discover SLU" :community.type/twitter]
  ["Magnolia Voice" :community.type/facebook-page]
  ["Blogging Georgetown" :community.type/facebook-page]
  ["Maple Leaf Life" :community.type/twitter]
  ["Fremont Universe" :community.type/twitter]
  ["Magnolia Voice" :community.type/twitter]
  ["Columbia Citizens" :community.type/facebook-page]
  ["Fauntleroy Community Association" :community.type/facebook-page]
  ["Columbia Citizens" :community.type/twitter]
  ["Eastlake Community Council" :community.type/facebook-page]
  ["Maple Leaf Life" :community.type/facebook-page]
  ["Fremont Universe" :community.type/facebook-page]
  ["Discover SLU" :community.type/facebook-page]
  ["MyWallingford" :community.type/twitter]
  ["MyWallingford" :community.type/facebook-page]}

引数で指定したタイプも :find の ?t で出すようにすることで、指定したタイプのコミュニティが抽出されている事が分かる。

更にもう一歩進んで、find-by-types でやった複数の条件を複数個渡す場合。

; t で指定したタイプのコミュニティ名を返す
(defn find-by-multi-types [t]
  (d/q '[:find ?n ?t ?ot
         :in $ [[?t ?ot]]
         :where
         [?c :community/name ?n]
         [?c :community/type ?t]]
       (d/db cn) t))

実行。渡すのはベクタベクタになる。

> (find-by-multi-types [[:community.type/email-list :community.orgtype/community]
			[:community.type/website :community.orgtype/commercial]])
#{["Fauntleroy Community Association" :community.type/website :community.orgtype/commercial]
  ["Ballard Neighbor Connection" :community.type/email-list :community.orgtype/community]
  ...}

関数呼び出し側から見ると全部 t の 1 個だからドキュメント付けとかないと 100% ワケわからなくなるな。

クエリ内関数(Invoking functions in queries)

以下の様な感じでクエリ内で関数を使用できる。最初見た時良く分からなかったのでコメントを後ろに付けた。

> (d/q '[:find ?n                ; 4. ?n を探せ
         :where
         [?c :community/name ?n] ; 1. コミュニティ名を ?n とした時、
         [(compare ?n "C") ?res] ; 2. ?n を "C" と比較した結果を ?res へ入れ、
         [(< ?res 0)]]           ; 3. ?res が 0 未満(= "C" より小さいアルファベット) な
     (d/db cn))

Clojure の as-> マクロの変形みたいなもんと考えれば覚えやすいかもしれない。as-> まんまとはいかずとも関数チェーンを許してるどうかは未確認。

上記クエリの結果はこちら。アルファベットで言う B 以前のコミュニティ名だけ抽出されているのが分かる。

#{["All About South Park"]
  ["Ballard Neighbor Connection"]
  ["Ballard Blog"]
  ["At Large in Ballard"]
  ["Ballard Chamber of Commerce"]
  ["Beacon Hill Burglaries"]
  ["Alki News"]
  ["Beacon Hill Alliance of Neighbors"]
  ["Beach Drive Blog"]
  ["Ballard Avenue"]
  ["Aurora Seattle"]
  ["Ballard Moms"]
  ["15th Ave Community"]
  ["All About Belltown"]
  ["Admiral Neighborhood Association"]
  ["Ballard District Council"]
  ["Beacon Hill Community Site"]
  ["ArtsWest"]
  ["Alki News/Alki Community Council"]
  ["Bike Works!"]
  ["Beacon Hill Blog"]
  ["Ballard Gossip Girl"]
  ["Blogging Georgetown"]
  ["Broadview Community Council"]
  ["Ballard Historical Society"]}

ちなみにクエリエンジンが名前空間なしに認識できる関数は java.lang と clojure.core のものだけらしい。「クエリエンジンが」とわざわざ書いてあると言う事は多分ソース内で use とかしても上記名前空間以外の関数は名前空間を前に付けないといけないんだろうがこれも未確認。

全文検索クエリ(Querying with fulltext search)

  • 前提条件
    • 属性の型が String タイプ
    • 属性定義の際に全文検索するような定義も併せて行う必要あり

こういう前提条件があるようだが、datomic では全文検索をサポートしているらしい。

:community/name は設定済み属性らしいので、その属性を元にしたサンプルはこちら。

; "Wallingford" が含まれているコミュニティ名を取得
> (d/q '[:find ?n
         :where
         [(fulltext $ :community/name "Wallingford") [[?e ?n]]]]
     (d/db cn))
#{["KOMO Communities - Wallingford"]}

fulltext はクエリエンジン組み込みの関数らしい。
3 つ引数を取り、上記サンプルだと

  • データベースの入力ソース→ $(これがイマイチ良く分からん)
  • 検索対象の属性名→ :community/name
  • 検索文字列→ "Wallingford"

結果を受け取るのが ?e ?n の部分。fulltext 関数の結果はエンティティと値で構成されたタプルを要素とするコレクションで、サンプルではあらかじめ destructuring して値だけ返すようにクエリを組んでる。

サンプルを少し書き換えたら分かる。

エンティティも見えるようにしたもの。(実際見えるのは数値で管理されたエンティティ ID)

> (d/q '[:find ?e ?n
         :where
         [(fulltext $ :community/name "Wallingford") [[?e ?n]]]]
     (d/db cn))
#{[17592186045605 "KOMO Communities - Wallingford"]}

結果をそのまま受け取るもの。

> (d/q '[:find ?c
         :where
         [(fulltext $ :community/name "Wallingford") ?c]]
     (d/db cn))
#{[#{[17592186045605 "KOMO Communities - Wallingford" 1021 1.0]}]}

"Wallingford" の部分をパラメータ化する事も可能。

; 指定したコミュニティのタイプ(?type)と文字列(?search)がカテゴリに含まれるものを探す
(defn find-by [type search]
  (d/q '[:find ?name ?cat
         :in $ ?type ?search
         :where
         [?c :community/name ?name]
         [?c :community/type ?type]
         [(fulltext $ :community/category ?search) [[?c ?cat]]]]
       (d/db cn)
       type search))

このクエリでは :community/category の定義を見ると分かるが Cardinality が Many となっている為、ここでも暗黙の JOIN がされている事になるのかな?。

実行例。

; タイプが website でカテゴリに "food" が含まれるコミュニティを返す
> (find-by :community.type/website "food")
#{["InBallard" "food"] ["Community Harvest of Southwest Seattle" "sustainable food"]}

ルールを適用したクエリ(Querying with rules)

ルールとは。
ごく簡単に言えば、よく使う :where 句に名前を付けて再利用・合成する事が可能な仕組み。

いきなりサンプル。クエリ部分だけ。

[[twitter ?c]
 [?c :community/type :community.type/twitter]]

最初の [] の中身は、twitter が名前。?c がルールでの引数。
次の [
] が本体。

このルールの意味合いはコミュニティのタイプが twitter な ?c が条件と言った所。
また、ルールには複数の別ルールを含める事が可能。

実際の定義ではそれが前提になっているようで、上記サンプルのような一個のルールでもこんな感じで [] で囲んでやる必要があるみたい。

[[[twitter ?c]
  [?c :community/type :community.type/twitter]]]

では定義・呼び出し・実行結果までのサンプル。

> (def rules '[[[twitter ?c]
                [?c :community/type :community.type/twitter]]])
rules

> (d/q '[:find ?n :in $ % :where [?c :community/name ?n] (twitter ?c)]
       (d/db cn)
       rules)
#{["Discover SLU"]
  ["Fremont Universe"]
  ["Columbia Citizens"]
  ["MyWallingford"]
  ["Maple Leaf Life"]
  ["Magnolia Voice"]}
  • ルールを :find で使う為に覚えるべきこと
    • ルールを外部から受け取るには :in 句内で '%' を使う
    • 定義されてる名前で :where 句内で呼び出す
    • 視認性の為ルールは()で囲むのを推奨との事だが、必須ではない

条件一つのルールじゃ正直有り難みがないので、複数条件を指定してみる。前回記事で「北西(:region/ne)にあるコミュニティ名を取得する」クエリの例があったが、この条件部分をルール化する。

> (def rule-region
    '[[[region ?c ?r]
       [?c :community/neighborhood ?n]
       [?n :neighborhood/district ?d]
       [?d :district/region ?re]
       [?re :db/ident ?r]]])
#'datomic-tut.core/rule-region

> (d/q '[:find ?n
         :in $ %
         :where
         [?c :community/name ?n]
         (region ?c :region/ne)]
       (d/db cn) rule-region)
#{["KOMO Communities - U-District"]
  ["Hawthorne Hills Community Website"]
  ...}

> (d/q '[:find ?n
         :in $ %
         :where
         [?c :community/name ?n]
         (region ?c :region/sw)]
       (d/db cn) rule-region)
#{["Greenwood Community Council Announcements"]
  ["Genesee-Schmitz Neighborhood Council"]
  ...}

ついでにパラメータ化で region がどこでも対応できるようになってる。

ルールで OR 条件を作りたい場合はこう。クエリ部分のみ。

[[[social-media ?c]
  [?c :community/type :community.type/twitter]]
 [[social-media ?c]
  [?c :community/type :community.type/facebook-page]]]

これを呼び出せば twitter or facebook なコミュニティが抽出される。

最初に書かれていたルール定義時は複数のルールに名前を付けて渡す事も可能でこんな感じで定義できる。

(def rule-set
  '[[[region ?c ?r]
     [?c :community/neighborhood ?n]
     [?n :neighborhood/district ?d]
     [?d :district/region ?re]
     [?re :db/ident ?r]]
    [[social-media ?c]
     [?c :community/type :community.type/twitter]]
    [[social-media ?c]
     [?c :community/type :community.type/facebook-page]]
    [[northern ?c] (region ?c :region/ne)]
    [[northern ?c] (region ?c :region/n)]
    [[northern ?c] (region ?c :region/nw)]
    [[southern ?c] (region ?c :region/sw)]
    [[southern ?c] (region ?c :region/s)]
    [[southern ?c] (region ?c :region/se)]])

ルール名 northern と southern を見ると分かるが、ルール本体で別のルールを呼び出す事も可能。

上記ルールを使ったサンプル。

; 南方にあるソーシャルメディア(twitter/facebook)を使ったコミュニティを取得
> (d/q '[:find ?n
         :in $ %
         :where
         [?c :community/name ?n]
         (southern ?c)
         (social-media ?c)]
       (d/db cn) rule-set)
#{["Blogging Georgetown"]
  ["Columbia Citizens"]
  ["MyWallingford"]
  ["Fauntleroy Community Association"]}

今日はここまで。