mike-neckのブログ

Java or Groovy or Swift or Golang

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

今日はユニーク制約の話です。


ユニーク制約はRDBMSの中でも使われる考え方です。

datomicではアトリビュートを定義する際に:db/uniqueに以下の二種類のユニーク制約をつけることができます(つけない場合はユニーク制約なし)。

  • :db.unique/value
  • :db.unique/identity

:db.unique/value

アトリビュート:db.unique/valueを付与した場合、そのアトリビュートとして登録できるデータはエンティティで一意の値になるようにdatomicが制約を設けます。一意にならない(重複した)データを登録した場合は、例外が発生します。

なお、本日のコードに以下のようなコードが出てきます。これはスキーマ定義用/データ追加用のトランザクションdata structureを簡易的に作るユーティリティクラスです。

//スキーマ定義用のマップを作る
def name = SchemaDefinition
        .entity('customer')
        .attr('name')
        .type(ValueType.STRING)
        .one().with(':db/unique': 'db.unique/value')
name.asMap()
// -> {
//    :db/id #db/id[db.part/db],
//    :db/ident :customer/name,
//    :db/valueType :db.type/string,
//    :db/cardinality :db.cardinality/one,
//    :db/unique :db.unique/value
// }

//データ追加のリストを作る
ListTransaction.useId(-1)
        .create(entity: 'customer',
                attr: 'name',
                value: 'Hoge')
        .transaction
// -> [
//    :db/add #db/id[db.part/user -1]
//    :customer/name
//    "Hoge"
// ]

ではアトリビュート:db.unique/valueの制約をつけて、一意制約違反するデータを登録して例外が発生するテストコードを見てみたいと思います。

@Test
void uniqueがvalueの場合() {
    def name = SchemaDefinition
            .entity('customer')
            .attr('name')
            .type(ValueType.STRING)
            .one().with(':db/unique': 'db.unique/value')
    def age = SchemaDefinition
            .entity('customer')
            .attr('age')
            .type(ValueType.LONG)
            .one()
    connection.transact([name, age].collect{it.asMap()}).get()
    log 'schema defined'
    def tx = [
            ListTransaction.useId(-1)
                .create(entity: 'customer',
                            attr: 'name',
                            value: 'Hoge').transaction,
            ListTransaction.useId(-1)
                .create(entity: 'customer',
                            attr: 'age',
                            value: 30).transaction,
            ListTransaction.useId(-2)
                .create(entity: 'customer',
                            attr: 'name',
                            value: 'Foo').transaction,
            ListTransaction.useId(-2)
                .create(entity: 'customer',
                            attr: 'age',
                            value: 20).transaction
    ]
    connection.transact(tx).get()
    def errorTx = [
            ListTransaction.useId(-1)
                .create(entity: 'customer',
                            attr: 'name',
                            value: 'Hoge').transaction,
            ListTransaction.useId(-1)
                .create(entity: 'customer',
                            attr: 'age',
                            value: 20).transaction,
    ]
    try {
        connection.transact(errorTx).get()
        fail()
    } catch (ExecutionException e) {
        assert e.cause instanceof IllegalStateException
        log e.message
    }
}

上記のテストでは事前に:customer/name"Hoge"となるデータを登録しておいて、その後に改めて:customer/name"Hoge"となる新しいデータを登録しようとするテストです。もしデータ登録で例外が発生しないようでしたら、Assert#fail()に到達してテストが落ちるようになっています。また例外が発生した場合に、その例外の原因がIllegalStateExceptionであることを確認します。最後にメッセージを表示します。

このテストを実行すると次のようにログが出力されて、テストがパスします。

java.lang.IllegalStateException: :db.error/unique-conflict Unique conflict: :customer/name, value: Hoge already held by: 17592186045418 asserted for: 17592186045421

ユニーク制約を違反していて、:customer/name"Hoge"のデータは既に登録されているというメッセージが出ています。

ここまでのまとめ

:db.unique/valueで一意制約違反すると例外が発生する


:db.unique/identity

一方の:db.unique/identityでは一意制約は保証されますが、一意制約に反する新しいデータが登録される場合、元にあったデータにマージされます。つまり、単純に一時IDがどのように解決されるかにかかわらず、エンティティの更新として認識されてしまうということです。

では試してみたいと思います。

@Test
void uniqueがidentityの場合() {
    def schema = [
            SchemaDefinition
                    .entity('customer')
                    .attr('name')
                    .type(ValueType.STRING)
                    .one().with(':db/unique': 'db.unique/identity'),
            SchemaDefinition
                    .entity('customer')
                    .attr('age')
                    .type(ValueType.LONG)
                    .one(),
            SchemaDefinition
                    .entity('customer')
                    .attr('favorite')
                    .type(ValueType.STRING)
                    .many()
    ]
    connection.transact(schema.collect{it.asMap()}).get()
    log 'schema defined'
    def tx = [
            ListTransaction.useId(-1)
                    .create(entity: 'customer',
                            attr: 'name',
                            value: 'Hoge').transaction,
            ListTransaction.useId(-1)
                    .create(entity: 'customer',
                            attr: 'age',
                            value: 30).transaction,
            ListTransaction.useId(-1)
                    .create(entity: 'customer',
                            attr: 'favorite',
                            value: 'sushi').transaction,
            ListTransaction.useId(-1)
                    .create(entity: 'customer',
                            attr: 'favorite',
                            value: 'steak').transaction,
            ListTransaction.useId(-2)
                    .create(entity: 'customer',
                            attr: 'name',
                            value: 'Foo').transaction,
            ListTransaction.useId(-2)
                    .create(entity: 'customer',
                            attr: 'age',
                            value: 40).transaction,
            ListTransaction.useId(-2)
                    .create(entity: 'customer',
                            attr: 'favorite',
                            value: 'noodle').transaction
    ]
    connection.transact(tx).get()
    def mergeTx = [
            ListTransaction.useId(-1)
                    .create(entity: 'customer', attr: 'name', value: 'Hoge').transaction,
            ListTransaction.useId(-1)
                    .create(entity: 'customer', attr: 'age', value: 20).transaction,
            ListTransaction.useId(-1)
                    .create(entity: 'customer', attr: 'favorite', value: 'fau').transaction
    ]
    connection.transact(mergeTx).get()
    def db = connection.db()
    def query = '''[
:find
    ?name
    ?age
    ?fav
:where
    [?c :customer/name ?name]
    [?c :customer/age ?age]
    [?c :customer/favorite ?fav]]'''
    def results = Peer.query(query, db)
    assert results.size() == 4
    results.sort{it[0]}.each {vec ->
        log "name: [${vec[0]}], age: [${vec[1]}], fav: [${vec[2]}]"
    }
}

上記のテストでは下記のエンティティに対してデータを追加していきます。

アトリビュート カーディナリティ unique
:customer/name string one identity
:customer/age long one -
:customer/favorite string many -

そして事前に登録しておいた:customer/name"Hoge"のデータに対して、改めて:customer/name"Hoge"のデータの登録を試みます。

実行すると、カーディナリティがmanyの:customer/favoriteでデータを追加した分、結果のデータ数が増加して次のようなログが出力されます。

name: [Foo], age: [20], fav: [noodle]
name: [Hoge], age: [20], fav: [fau]
name: [Hoge], age: [20], fav: [sushi]
name: [Hoge], age: [20], fav: [steak]

元々は:customer/name"Hoge"のデータは:customer/age30であり、:customer/favoriteが2つであったのが、:customer/age20に更新されて、:customer/favoriteが1つ追加されてしまっています。これは:db/unique:db.unique/identityの場合に同じ値のデータが登録されようとすると、一時IDがどのように解決されようと、元にあったデータにマージされるという機能によるものです。

まとめ

以上、datomicでユニーク制約がどのように実現されているかを見てきました。なかなかdatomicの面白い性質が見られたと思います。

次回は:db.type/refのあたりの詳細を、その次にユーザー定義パーティションの作成の方法を見て行きたいと思います。

その二つが終わったら、少し僕がネタ集めをした後に、実際にRDBやmongodbのあたりをdatomicのデータストレージとして利用する方法をやっていきたいと思います。


おわり

あ、うつ病かつADHDな僕を雇ってみてもいいよ、お仕事ちょっと依頼したいよという企業さん大歓迎ですので、ぜひ連絡ください(乞食)