mike-neckのブログ

Java or Groovy or Swift or Golang

datomicチュートリアル10日目 #datomic

今日はデータの削除をやります。


データの削除に用いるトランザクションのデータ構造

データ削除に用いるトランザクションのデータ構造は次の二通りがあります。

属性の値を削除する方法

[
[:db/retract entity-id attribute-name value]
...
]

:db/retractで始まるリストで指定したエンティティの指定した属性の指定した値を削除します。指定した値が現在の値と異なる場合は何も起こりません。

エンティティを削除する方法

[
[:db.fn/retractEntity entity-id]
]

:db.fn/retractEntityは組み込みの関数で、指定したエンティティのすべての属性を削除します。


これらは、前回前々回で紹介したリスト(:db/add)形式/マップ(:db/id)形式に対応しているような感じです。

指定した値を削除するとは?

では、指定した値を削除するというのがどういう意味なのか、確認してみましょう。なお、今日のテスト用のデータも昨日と同じデータを用います。

まず、エンティティIDを1件取得するクエリーを発行します。これは次のようなクエリーで取得できます。

[
:find ?c .
:where [?c :community/name "Hoge"]]

これは、:community/nameが"Hoge"の:communityのエンティティIDを1件だけ取得します。結果が複数あっても任意の1件のエンティティIDが返されます。このクエリーを実行する場合は条件を必ず一意になるように指定するように気をつける必要があります。というのも、狙い通りのエンティティに対して更新・削除の操作ができないからです。その後、:db/retractを用いてエンティティに設定してある値を削除します。

private List<String> attributes =
        ['name', 'url', 'category', 'orgtype', 'neighborhood', 'type']

private void log(String s) {
    LOG.debug "[${test.methodName}] ${s}"
}

static String FIND_HOGE_ID = $/[
:find
    ?c .
:where
    [?c :community/name "Hoge"]]/$

@Test
void retractSample() {
    // :community/nameがHogeのエンティティIDを取得する
    long id = Peer.query(FIND_HOGE_ID, db)
    // 上記のエンティティの:community/orgtypeの値:community.orgtype/commercialを削除します
    def transaction = "[[:db/retract ${id} :community/orgtype :community.orgtype/commercial]]"
    def tx = DatomicUtil.from(new StringReader(transaction))[0]
    Database afterDb = connection.transact(tx).get().get(Connection.DB_AFTER)
    // 削除後に同一条件でエンティティIDを取得します
    long afterId = Peer.query(FIND_HOGE_ID, afterDb)
    // エンティティIDに変更がないことを検証します
    assert id == afterId
    logData('before', db, id)
    logData('after', afterDb, afterId)
}
private void logData(String when, Database database, long id) {
    def entity = database.entity(id)
    def values = attributes.toKeywords().collect {
        def value = entity.get(it)
        "${it}: [${value}]"
    }.join(', ')
    log "$when id: [$id], ${values}"
}

テストデータで:community/nameが"Hoge"のデータは1件だけですので、最初のクエリーはレコードを一意に取得してきます。テストデータの:community/orgtypeはすべて:community.orgtype/commercialに設定されていますので、:db/retractはこの値が設定されたという事実を削除します。しかし、エンティティそのものが削除されるわけではないので、変更後のデータベースから同じ条件でレコードを取得すると、同じIDが返されることが想定されます。そこで、エンティティIDが同一であることを検証しています。最後の方にあるlogDataというメソッドは変更前のエンティティと変更後のエンティティを表示するメソッドです。

これを実行してみましょう。

datomic.db - {:tid 1, :pid 51039, :phase :begin, :event :db/add-fulltext}
datomic.db - {:tid 1, :pid 51039, :phase :end, :msec 0.0455, :event :db/add-fulltext}
before id: [17592186045656], :community/name: [Hoge], :community/url: [http://localhost:8000/hoge], :community/category: [[kudoh, test]], :community/orgtype: [:community.orgtype/commercial], :community/neighborhood: [{:db/id 17592186045655}], :community/type: [:community.type/facebook-page]
after id: [17592186045656], :community/name: [Hoge], :community/url: [http://localhost:8000/hoge], :community/category: [[kudoh, test]], :community/orgtype: [null], :community/neighborhood: [{:db/id 17592186045655}], :community/type: [:community.type/facebook-page]

この結果を表で表すと次のようになります。

属性 変更前 変更後
:community/name Hoge Hoge
:community/url http://localhost:8000/hoge http://localhost:8000/hoge
:community/category kudoh、test kudoh、test
:community/orgtype :community.orgtype/commercial null
:community/neighborhood 17592186045655 17592186045655
:community/type :community.type/facebook-page :community.type/facebook-page

予定通り:community/orgtypeの値が:community.orgtype/commercialからnullに変わりました。そして、他の値は変更されていません。このような形で:db/retractは指定外の属性の値はそのまま残しつつ、指定した属性の指定した値を削除します。

では、指定した値が間違えていた場合はどうなるのでしょうか?これも試してみましょう。

@Test
void retractAntiSample() {
    // :community/nameがHogeのエンティティIDを取得する
    long id = Peer.query(FIND_HOGE_ID, db)
    // 上記のエンティティの:community/orgtypeの値:community.orgtype/commercialを削除します
    def transaction = "[[:db/retract ${id} :community/orgtype :community.orgtype/community]]"
    def tx = DatomicUtil.from(new StringReader(transaction))[0]
    Database afterDb = connection.transact(tx).get().get(Connection.DB_AFTER)
    // 削除後に同一条件でエンティティIDを取得します
    long afterId = Peer.query(FIND_HOGE_ID, afterDb)
    // エンティティIDに変更がないことを検証します
    assert id == afterId
    // :community/orgtypeに変更がないことを検証します
    assert db.entity(id).get(':community/orgtype') == afterDb.entity(id).get(':community/orgtype')
    log "before ${db.entity(id).get(':community/orgtype')}"
    log "after ${afterDb.entity(id).get(':community/orgtype')}"
}

実行するとテストはパスして、次のように変更されていないことが確認できます。

before :community.orgtype/commercial
after :community.orgtype/commercial

:db/retractでエンティティ1件を削除する

:db/retractでエンティティ1件を削除する場合は、次のようなdata structureを実行します。

  • エンティティのすべての要素に対して:db/retractを実行する
  • 属性のカーディナリティがManyの場合は、すべての値について:db/retractを実行する
  • 属性が他のエンティティへの参照の場合は、参照先のエンティティIDをvalueに指定する

上記の要件を満たしつつ、:community/nameが"Hoge"であるエンティティを削除するプログラムは次のようになります。

@Test
void dbRetractでエンティティを削除する() {
    //:community/nameがHogeのエンティティIDを取得する
    def id = Peer.query(FIND_HOGE_ID, db)
    def entity = db.entity(id)
    //トランザクション作成
    def transactions = attributes.toKeywords().collect {attr ->
        //値の型によって指定する値が異なる
        def value = entity.get(attr)
        def klass = value.getClass()
        //カーディナリティがManyの場合はリストにして後でflatten()する
        klass.equals(PersistentHashSet) ?
                value.collect {[attribute: attr, value: it]}:
        //参照型の場合は、:db/idを指定する
        klass.equals(EntityMap) ?
                [attribute: attr, value: value.get(':db/id')]:
        //その他の場合はそのまま使う
                [attribute: attr, value: value]
    }.flatten().collect {
        //トランザクションのデータ構造に変換
        def dq = it.value.getClass().equals(Long)? '' : '"'
        "[:db/retract ${id} ${it.attribute} $dq${it.value}$dq]"
    }.join('\n')
    //リストのリストを作る
    def transaction = $/[
$transactions
]/$
    log transaction
    //トランザクション実行
    def tx = DatomicUtil.from(new StringReader(transaction))
    def afterDb = connection.transact(tx[0]).get().get(Connection.DB_AFTER)
    //:community/nameがHogeのエンティティIDは存在しない
    def afterId = Peer.query(FIND_HOGE_ID, afterDb)
    assert afterId == null
    //元のIDからエンティティは取り出せる
    def e = afterDb.entity(id)
    assert e != null
    //ただし要素は何もない
    assert e.keySet().size() == 0
}

一度取得してきたidから各属性の値を取得した後、:db/retractのリストを作成します。作成されたトランザクションdata structureでトランザクションを発生させて、変更後のデータベースから同一条件でデータを検索します。この時、条件に一致するエンティティは存在しないので、nullが返ってきます。そして、次が面白いところですが、最初に取得したIDからエンティティ(EntityMap)への変換を行うことはできます。ただし、属性が一つもないのでkeySet()のサイズは0件です。

途中でattributes.toKeywords()というのを呼び出していますが、これは次のように@Beforeの際にGroovyのメタクラスを用いて拡張したメソッドで、属性のキーワードに変更するメソッドです。

attributes.metaClass.define {
    toKeywords = {
        return delegate.collect {
            ":community/${it}" as String
        }
    }
}

これを実行すると、実際のトランザクションdata structureが表示されます。

[
[:db/retract 17592186045656 :community/name "Hoge"]
[:db/retract 17592186045656 :community/url "http://localhost:8000/hoge"]
[:db/retract 17592186045656 :community/category "kudoh"]
[:db/retract 17592186045656 :community/category "test"]
[:db/retract 17592186045656 :community/orgtype ":community.orgtype/commercial"]
[:db/retract 17592186045656 :community/neighborhood 17592186045655]
[:db/retract 17592186045656 :community/type ":community.type/facebook-page"]
]

このように、大量のトランザクションdata structureを作成しないといけないため、非常に面倒いです。そこで、Datomicには

エンティティを削除する:db.fn/retractEntityという組み込み関数

が用意されています。

これは先ほど書いたようにエンティティIDを指定すれば、そのエンティティが削除されます。

@Test
void dbFnRetractでエンティティを削除する() {
    // :community/nameがHogeのエンティティIDを取得する
    def id = Peer.query(FIND_HOGE_ID, db)
    // データ取消
    def transaction = "[[:db.fn/retractEntity ${id}]]"
    log transaction
    def tx = DatomicUtil.from(new StringReader(transaction))
    def afterDb = connection.transact(tx[0]).get().get(Connection.DB_AFTER)
    //:community/nameがHogeのエンティティIDは存在しない
    def afterId = Peer.query(FIND_HOGE_ID, afterDb)
    assert afterId == null
    //元のIDからエンティティは取り出せる
    def e = afterDb.entity(id)
    assert e != null
    //ただし要素は何もない
    assert e.keySet().size() == 0
}

先ほどのコードに比べて、かなり短くなっていることがわかります。そして、得られる結果は同一です。

API経由で

ここまでは文字列を組み立てていましたが、リストでもできます。

:db/retractの場合

    def tx = attributes.toKeywords().collect {attr ->
        //値の型によって指定する値が異なる
        def value = entity.get(attr)
        def klass = value.getClass()
        //カーディナリティがManyの場合はリストにして後でflatten()する
        klass.equals(PersistentHashSet) ?
                value.collect {[attribute: attr, value: it]}:
        //参照型の場合は、:db/idを指定する
        klass.equals(EntityMap) ?
                [attribute: attr, value: value.get(':db/id')]:
        //その他の場合はそのまま使う
                [attribute: attr, value: value]
    }.flatten().collect {
        [':db/retract', id, it.attribute, it.value]
    }
    def afterDb = connection.transact(tx).get().get(Connection.DB_AFTER)

:db.fn/retractEntityの場合

    def tx = [[':db.fn/retractEntity', id]]
    def afterDb = connection.transact(tx).get().get(Connection.DB_AFTER)

以上。次回はカーディナリティがManyのアトリビュートの更新についてです。