今日はユニーク制約の話です。
ユニーク制約は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/age
が30
であり、:customer/favorite
が2つであったのが、:customer/age
が20
に更新されて、:customer/favorite
が1つ追加されてしまっています。これは:db/unique
が:db.unique/identity
の場合に同じ値のデータが登録されようとすると、一時IDがどのように解決されようと、元にあったデータにマージされるという機能によるものです。
まとめ
以上、datomicでユニーク制約がどのように実現されているかを見てきました。なかなかdatomicの面白い性質が見られたと思います。
次回は:db.type/ref
のあたりの詳細を、その次にユーザー定義パーティションの作成の方法を見て行きたいと思います。
その二つが終わったら、少し僕がネタ集めをした後に、実際にRDBやmongodbのあたりをdatomicのデータストレージとして利用する方法をやっていきたいと思います。
おわり
あ、うつ病かつADHDな僕を雇ってみてもいいよ、お仕事ちょっと依頼したいよという企業さん大歓迎ですので、ぜひ連絡ください(乞食)