日曜プログラミング

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

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 については後述