GrailsのSpring Security Core Pluginでパスワードのソルトとストレッチング
パスワードをハッシュで保存して置くのは当たり前ですが、レインボーテーブル使用した総当たり探索の対策として、ソルトとストレッチングを組み合わせ、より安全にパスワードを保存するのが一般的になってきました。
GrailsのSpring Security Core PluginはデフォルトではSHA-256
のハッシュでパスワードを保存しますが、ソルトやストレッチングは使用されていません。しかし、そこは認証のデパートSpring Security、ソルトとストレッチングのサポートが組み込まれています。
まずはソルトのサポートから見て行きましょう。
usernameをソルトとする
usernameをソルトとして扱うにはConfig.groovy
に以下の設定を追加します。
grails.plugins.springsecurity.dao.reflectionSaltSourceProperty = 'username'
これで認証時に、usernameをソルトとしてパスワードに付与しハッシュを計算するようになります。ただし、この設定はあくまで認証時の設定であり、ユーザのパスワードを保存する時にソルトが付与されるわけではありません。
もし、s2-quickstart
コマンドを使って生成したユーザドメインクラスを使用している場合は、このドメインクラスに手を入れます。デフォルトでは以下のようにパスワードがエンコードされています。
protected void encodePassword() { password = springSecurityService.encodePassword(password) }
これを以下のように変更します。
protected void encodePassword() { password = springSecurityService.encodePassword(password, username) }
この様にspringSecurityService#encodePasswordの第2引数にソルトとしてusernameを指定します。
これでパスワード保存時にusernameをソルトとして付与してハッシュが計算されます。
ソルト専用のプロパティを使う
徳丸先生の資料によるとソルトには一定の長さが必要です。usernameとpasswordの組み合わせで一定以上の長さが確保できない場合は、一定の長さをもつソルト専用のプロパティが欲しくなります。
以下のようにユーザドメインクラスを変更します。
import org.apache.commons.lang.RandomStringUtils class User { ... String salt String getSalt() { // 新規作成時にsaltがない場合にsaltを生成 if (!salt) { // ランダムである必要ないが一定の長さをもつ文字列として // ランダムが扱いやすいのでRandomStringUtilsを使用 salt = RandomStringUtils.randomAlphanumeric(20) // ランダムな20文字 } return salt } static constraints = { ... salt blank: false } static mapping = { ... // 安全のためinsert以外でsaltが更新されないようにする salt updateable: false } protected void encodePassword() { // usernameをsaltに変更 password = springSecurityService.encodePassword(password, salt) } }
合わせてConfig.groovy
のreflectionSaltSourceProperty
も変更しましょう。
grails.plugins.springsecurity.dao.reflectionSaltSourceProperty = 'salt'
これで設定完了!と言いたいところですが、まだいくつかの準備が必要です。
上記のreflectionSaltSourceProperty
はSpringSecurityのUserDetails
を実装したクラスのプロパティを指定しています。GrailsのSpring Security Core PluginのデフォルトではUserDetails
の実装クラスにGrailsUser
が使われます。
しかし、GrailsUser
にはsalt
というプロパティは存在しないため、新たにクラスを用意する必要があります。
また、このUserDetails
のインスタンスはUserDetailsService
の実装クラスで生成されます。GrailsのSpring Security Core Pluginでは、これがGormUserDetailsService
というクラスになります。
つまり独自のUserDetailsService
を作成し、salt
というプロパティを持つ独自のUserDetails
を生成する必要があります。
クラスはsrc/groovy
などに作成すると良いでしょう。
まずはUserDetails
の実装クラスを以下のよう作成します。
import org.codehaus.groovy.grails.plugins.springsecurity.GrailsUser import org.springframework.security.core.userdetails.UserDetails class MyUser implements UserDetails { @Delegate GrailsUser grailsUser String salt }
次に上記のMyUser
クラスを生成するUserDetailsService
を作ります。既存のGormUserDetailsService
を継承して作るがカンタンです。
import org.codehaus.groovy.grails.plugins.springsecurity.GormUserDetailsService import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.userdetails.UserDetails class MyUserDetailsService extends GormUserDetailsService { @Override protected UserDetails createUserDetails(user, Collection<GrantedAuthority> authorities) { new MyUser(grailsUser: super.createUserDetails(user, authorities), salt: user.salt) } }
groovyの@Delegate
を使用したお手軽実装にしてみました。
あとはこのサービスをSpringのビーンとして登録します。resource.groovy
に以下のように追加してください。
beans = { ... userDetailsService(MyUserDetailsService) { grailsApplication = ref('grailsApplication') } }
これでソルト専用のプロパティを使うことができます。
ストレッチング
GrailsのSpring Security Core PluginのデフォルトではpasswordEncoder
にorg.springframework.security.authentication.encoding.MessageDigestPasswordEncoder
が使われています。
このクラスはストレッチングをサポートしています。
しかし、プラグインの設定ではgrails.plugins.springsecurity.password.algorithm
というハッシュアルゴリズムの設定があるだけで、ストレッチング回数の設定はありません(デフォルトでは1回です)。
自分で新たにビーンを定義することもできますが、ここはGrailsが持つ、設定ファイルからSpringビーンのプロパティを上書きする仕組みを活用します。
Config.groovy
に以下の設定を追加します。
beans.passwordEncoder.iterations = 1000
これで1000回ストレッチングします。
テスト
ソルトとストレッチングが機能しているか不安になるのでテストを書いてみましょう。
def "ソルトとストレッチングの確認"() { setup: def password = "password" def user = new User(username: "test", password: password, enabled: true) and: "パスワードにランダム文字列をソルトとして付与" def digest = "$password{$user.salt}".getBytes("UTF-8") and: "1000回ストレッチング" 1000.times { digest = MessageDigest.getInstance("SHA-256").digest(digest) } def hashedPassword = new String(Hex.encode(digest)) when: "新規Userを保存" user.save() then: "ソルトとストレッチングを使用したハッシュになっていること" user.password == hashedPassword }
うまく動いているようです。
おわりに
ということでソルトとストレッチングで安全にパスワードを保存しましょう。 プラグインのリファレンスは以下を詳しく見ておくとよいです。
なお、この記事の内容は自己責任でご利用ください。ではでは。
この記事はGrails2.2.1、Spring Security Core Plugin1.2.7.3をもとに記述しています。
GrailsでCucumberを使用する
Ruby、Railsの世界では、プレーンテキストで記述できる受け入れテストツールとして有名なCucumberですが、Pure-Javaで実装されたcucumber-jvmのお陰で、Grailsでもプラグインをインストールすることで簡単にcucumberが利用できるようなります。
準備
cucumber pluginをBuildConfig.groovy
に追加しインストールします。
plugins {
...
test ":cucumber:0.8.0"
}
featureの準備
デフォルトではcucumberに必要なfeatureやstepファイルはtest/functional
に格納します。これは設定ファイルで変更することも可能です。
まず始めに以下の様なfeatureをtest/functional/NewBook.feature
に作成します。
#language: ja
フィーチャ: 新しい本の登録
本の所有者として
私はBookTrackerに自分の本を追加したい
自分でそれを覚えておく必要がないように
シナリオ: 新しい本
前提 BookTrackerを開く
もし "Specification by Example"を追加する
ならば "Specification by Example"の詳細を参照できる
日本語でfeatureファイルを記述するには#language: ja
をファイルの先頭に追加する必要があります。
実行
実行はGrailsのfuctionalテストとして実行します。
grails test-app functional:
grails test-app :cucumber
まだstepを実装していないため、以下のようなエラーが出力されるはずです。
You can implement missing steps with the snippets below:
前提(~'^BookTrackerを開く$') { ->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
もし(~'^"([^"]*)"を追加する$') { String arg1 ->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
ならば(~'^"([^"]*)"の詳細を参照できる$') { String arg1 ->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
stepの実装
test/functional/steps/Book_steps.groovy
を作成してstepを実装していきます。デフォルトではstepはtest/functional/steps/
ディレクトリに格納する必要があります。step実装の主な流れは以下のようになります。
- 先ほどの出力をコピーする
PendingException
をインポートするJA
のlanguageをインポートする- (ダブルクオートで使用している場合は$をエスケープする、GStringの制約のため)
これを実施すると以下のようなファイルになります。
import cucumber.runtime.PendingException
this.metaClass.mixin(cucumber.api.groovy.JA)
// 以下のようにstaticインポートしても同じ
//import static cucumber.api.groovy.JA.*
前提(~'^BookTrackerを開く$') {->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
もし(~'^"([^"]*)"を追加する$') { String arg1 ->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
ならば(~'^"([^"]*)"の詳細を参照できる$') { String arg1 ->
// Express the Regexp above with the code you wish you had
throw new PendingException()
}
ここで再度テストを実行すると、PendingException
がスローされます。それでは次に必要な実装を追加していきます。
前提(~'^BookTrackerを開く$')
必要なセットアップがあるならここで実装を追加しますが、今回は特に必要なセットアップが存在しないため、単に何もしないように変更します。
前提(~'^BookTrackerを開く$') {->
// NOP
}
もし(~'^"([^"]*)"を追加する$')
まず、必要となるdomainとcontrollerを実装します。今回作成するcontrollerは単にJSONを返すインタフェースになっています。
domain:
package books
class Book {
String title
}
controller:
package books
import grails.converters.JSON
class BookController {
def add() {
render new Book(params).save() as JSON
}
}
次にstepを次のように実装します。
もし(~'^"([^"]*)"を追加する$') { String bookTitle ->
bookController = new BookController()
bookController.params.title = bookTitle
bookController.add()
}
ここでは詳細には説明しませんが、これはGrailsにおけるcontrollerのテストの仕組みにそってテスト実装する必要があります。
この状態で再度テストを実行するとjava.lang.IllegalStateException
がスローされます。
これを解消するには、コントローラが外部から呼ばれているように見せるために、コントローラのテストに必要なセットアップとクリーンアップのコードを追加する必要があります。
Before & After
test/functional/hooks/env.groovy
に以下のようなファイルを追加します。これによりコントローラをテストする際のモック機能が有効になります。
import org.codehaus.groovy.grails.test.support.GrailsTestRequestEnvironmentInterceptor
this.metaClass.mixin(cucumber.api.groovy.Hooks)
GrailsTestRequestEnvironmentInterceptor scenarioInterceptor
Before() {
scenarioInterceptor = new GrailsTestRequestEnvironmentInterceptor(appCtx)
scenarioInterceptor.init()
}
After() {
scenarioInterceptor.destroy()
}
この状態でテストを実施すると、先ほどのjava.lang.IllegalStateException
がスローされずにstepの最後のブロックで、PendingExceptionが発生します。
ならば(~'^"([^"]*)"の詳細を参照できる$')
以下のように実装します。
ならば(~'^"([^"]*)"の詳細を参照できる$') { String bookTitle ->
def actual = bookController.response.json
assert actual.id
assert actual.title == bookTitle
}
controllerのレスポンスに格納されているjsonの値を参照し、saveした際に生成されるidと、paramsから取得したtitleが正しく設定されているか検証しています。
最後にもう一度テストを実行してみます。
$ grails test-app functional:
| Server running. Browse to http://localhost:8080/cucumber
| Running 1 cucumber test...
| Completed 1 cucumber test, 0 failed in 2429ms
| Tests PASSED - view reports in /Users/yamkazu/IdeaProjects/grails-examples/cucumber/target/test-reports
うまくテストが通りました。
まとめ
Grailsで簡単にCucumberが利用できました! 次回は?Gebとの連携を紹介します。
参考
Database Migration Pluginでcontextを使用する
LiquibaseではchangeSetにcontext
属性が設定できます。このcontext
属性を使用することで、実行時に適用するchangeSetの範囲を指定できます。
databaseChangeLog = { changeSet(author: "yamkazu", id: "default-ctx") { ... } changeSet(author: "yamkazu", id: "prod-ctx", context: "prod") { ... } changeSet(author: "yamkazu", id: "test-ctx", context: "test") { ... } }
実行時にcontext
を指定するには--contexts
オプションを使用します。
grails> dbm-update --contexts="test"
いくつかの特徴的なルールがあるので以下にまとめます。
- 複数指定する場合はカンマで区切る
- changeSetで
context
が無指定の場合は常に実行される --contexts
を指定しないとすべてのコンテキストが対象になる
後ろ2つが若干わかりにくいですが、--contexts
の指定と実行されるid
は以下のようになります。
- dbm-update -
default-ctx
、test-ctx
、test-ctx
のchangeSetを実行 - dbm-update --contexts="test" -
default-ctx
、test-ctx
のchangeSetを実行 - dbm-update --contexts="test,prod" -
default-ctx
、test-ctx
、test-ctx
のchangeSetを実行
特に指定しないとすべてのコンテキスト実行される点は注意が必要です。例えばtest
のようなコンテキストを作成した時点で、本番環境に適用する際には明示的に何かのコンテキストを指定しないと、test
を含む全てのchangeSetが適用されるといった動作をします。この場合は実際にそのコンテキストが存在するかは別として、明示的に何らかの適当なコンテキストを指定しておく必要があります。
updateOnStart
でアプリケーション起動時に連動させるにはConfig.groovyでgrails.plugin.databasemigration.updateOnStartContext
を指定してください。
grails.plugin.databasemigration.updateOnStartContext="prod"
Database Migration Pluginで毎回実行するchangelogを定義する
runAlways
属性をtrue
に設定することで毎回実行するchangelogを定義できます。runOnChange
属性がtrue
の場合ではchangesetのチェックサムが変更になった時のみ実行しますが、runAlways
属性をtrue
にするとチェックサム変更有無に関係なく毎回実行してくれます。
changeSet(author: "yamkazu", id: "create-dummy-data", runAlways: true, runOnChange: true) { sql("DELETE FROM person") sql(""" |INSERT INTO person ( id, version, name ) VALUES ( nextval('hibernate_sequence'), 0, 'tanaka' ); |INSERT INTO person ( id, version, name ) VALUES ( nextval('hibernate_sequence'), 0, 'sato' ); """.stripMargin()) }
リファレンスを読む限り
Executes the change set on every run, even if it has been run before
と記述されておりrunAlways
だけ付与すれば動くように読めますが現時点(Database Migration Plugin 1.3.2、Liquibase 2.0.5)ではrunOnChange
をtrueにしないとチェックサムのエラーになります。
こんな投稿を発見したが、いまいち直感的じゃない気がします。
Database Migration PluginでNotNull制約のカラムを追加する
既存のデータが存在する場合に、NotNull
制約が付与されたカラムを追加する場合は少し工夫が必要です。単にカラムを追加すると既存のデータがNULL
になってしまうためエラーとなります。これを回避するには一度NotNull
制約を付与せずにカラムを追加し、既存データに対してUPDATE
をかけた上で、NotNull
制約を追加してあげる必要があります。
以下のドメインがあるとします。
class Person { String name }
以下のchangesetでデータベースと同期済みであるとします。
changeSet(author: "yamkazu (generated)", id: "1362294228819-1") { createTable(tableName: "person") { column(name: "id", type: "int8") { constraints(nullable: "false", primaryKey: "true", primaryKeyName: "personPK") } column(name: "version", type: "int8") { constraints(nullable: "false") } column(name: "name", type: "varchar(255)") { constraints(nullable: "false") } } }
さらにデータベースには以下のデータが入っているとします。
id | version | name ----+---------+-------- 1 | 0 | yamada 2 | 0 | sato
単純にカラムを追加するとエラーとなる
ドメインにage
のプロパティを追加します。
class Person { String name Integer age }
この状態でdbm-gorm-diff
コマンドを使用すると以下のようなchangelogを生成します。
changeSet(author: "yamkazu (generated)", id: "1362294947235-1") { addColumn(tableName: "person") { column(name: "age", type: "int4") { constraints(nullable: "false") } } }
このchangesetを反映するためにdbm-update
を実行します。
| Error 2013-03-03 16:19:10,773 [main] ERROR liquibase - Change Set changelog-0.1.groovy::1362294947235-1::yamkazu (generated) failed. Error: Error executing SQL ALTER TABLE person ADD age int4 NOT NULL: ERROR: column "age" contains null values Message: Error executing SQL ALTER TABLE person ADD age int4 NOT NULL: ERROR: column "age" contains null values
期待した通りエラーとなりました。
addNotNullConstraintを使用する
エラーを回避するためには、はじめに記述したように一度NotNull
制約を付与せずにカラムを追加し、既存データに対してUPDATE
をかけた上で、NotNull
制約を追加します。NotNull
制約を追加するにはaddNotNullConstraintが使用できます。
addNotNullConstraintの詳細はリファレンスを参照してください。
changeSet(author: "yamkazu (generated)", id: "1362294947235-1") { addColumn(tableName: "person") { column(name: "age", type: "int4") } sql("UPDATE person SET age = 30") addNotNullConstraint(tableName: "person", columnName: "age") rollback { dropColumn(tableName: "person", columnName: "age") } }
addColumn
でconstraints(nullable: "false")
とせず、(年齢を一律30才としていいかはおいといて)一度値を設定した後に、addNotNullConstraint
を使用してNotNull
制約を追加しています。
rollback
はchangeSet配下に複数のコマンドがある場合は自動でロールバック処理を作成しません。自動生成させるためにchangeSetをコマンド毎に分けるという案もありますが、ここではグループ化して、明示的にroolback
を指定しています。
addNotNullConstraintのdefaultNullValueを使用する
上記では明示的にUPDATE
をsql
コマンドを使用して設定しましたが、単純な値セットだけならばaddNotNullConstraintのdefaultNullValueが使用できます。
changeSet(author: "yamkazu (generated)", id: "1362294947235-1") { addColumn(tableName: "person") { column(name: "age", type: "int4") } addNotNullConstraint(tableName: "person", columnName: "age", defaultNullValue: "30") rollback { dropColumn(tableName: "person", columnName: "age") } }
defaultNullValueを使用すると以下のことを自動でやってくれます。
UPDATE person SET age = '30' WHERE age IS NULL; ALTER TABLE person ALTER COLUMN age SET NOT NULL;
単純な値セットであればdefaultNullValueで十分ですが、他のテーブル、カラムから値を算出するといった場合には使用できないため、その場合は先程のsql
コマンドなどを使用してください。
Database Migration Pluginで任意のSQLを実行する
Database Migration PluginではLiquibaseで使用可能なchangesetのコマンドが、groovyフォーマットのchangesetでも同様に使用可能になっています。
使用可能なコマンドの一覧はLiquibaseのリファレンスを参照してください。
今日はこの中からCustom SQLとCustom SQL Fileについて紹介します。
Custom SQL
changeSet(author: "yamkazu (generated)", id: "create-person") { sql("CREATE TABLE person ( id int8 primary key, name varchar(255) )") sql(""" |INSERT INTO person ( id, name ) VALUES ( 1, 'tanaka' ); |INSERT INTO person ( id, name ) VALUES ( 2, 'sato' ); """.stripMargin()) sql([stripComments: true, splitStatements: false], """ |INSERT INTO person ( id, name ) VALUES ( 3, 'suzuki' ); -- insert suzuki |INSERT INTO person ( id, name ) VALUES ( 4, 'yamamoto' ); -- insert yamamoto """.stripMargin()) rollback { sql("DELETE FROM person") dropTable(tableName: "person") } }
例を見ればだいたい使い方が想像できると思います。
一番シンプルな使い方はsql("...")
形式で実行したいSQLを指定するだけです。
属性を付与しつつ内容を記述する場合はsql([stripComments: true, splitStatements: false],"…")
のように第1引数にmapで属性を指定し、第2引数にクエリーの文字列を指定します。いくつか属性がありますがstripComments
属性にtrue
を指定するとコメントが削除され、splitStatements
にfalse
を指定すると;
でステートメントが分割されず、ひとつの1つのステートメントとして実行されます。
sql
コマンドを使用する際の注意点ですが、自動ロールバックに対応していないということです。createTable
といったコマンドではデフォルトでそれに対するロールバックが定義されていますが、sql
ではそれがないため明示的にrollback
コマンドを使用して、このchangeSetをロールバックする処理を記述する必要があります。
roolback
は直接SQLを記述したりchangeSetで使用可能なコマンドが使用できます。詳細はリファレンスを参照してください。
Custom SQL File
Custom SQL Fileは任意のSQLファイルを実行できます。
changeSet(author: "yamkazu (generated)", id: "create-person") { sqlFile(path: "create-person.sql") sqlFile(path: "sql/person-data-1.sql") sqlFile(path: "/sql/person-data-2.sql", stripComments: true, splitStatements: false) rollback { dropTable(tableName: "person") } }
path
にファイルを指定することで使用できます。ファイルはクラスパスから読みだされchangelog.groovyを起点としてた相対パス、または絶対パスが使用できます。
Custom SQLと同様にCustom SQL Fileも自動ロールバックには対応していないため、自身でロールバック処理を記述する必要があります。
Database Migration Plugin のチェックサム
データベースへの反映履歴を管理するdatabasechangelog
テーブルにはmd5sum
というchangeset
のチェックサムを格納するカラムがあります。
liquibaseのリファレンスには以下のように記述されています。
LiquiBase が変更セットに到達すると、MD5Sum を計算して、”databasechangelog” に MD5Sum を保存します。MD5Sum を保存する意味は、LiquiBase が、ほかの誰かが実行されて以来変更セットを変更していないかどうかを知ることができるためです。変更セットが実行されたときから変更されていた場合、LiquiBase はエラーとともに移行を終了します。というのも、何が変更されたか知ることができず、データベースが変更ログが期待しているのと異なった状態にあるかもしれないからです。もし、適切な理由によって変更セットが変更されていた場合やこのエラーを無視したい場合は、databasechangelog テーブルを更新して、その行の id/author/ファイルのパス名に対応する MD5Sum を null に更新します。次回 LiquiBase が実行されると、MD5Sum の値を適切な値に更新してくれます。
MD5Sum は、”runOnChange” 変更セット属性と一緒に使用されます。普通はただ現在のバージョンが知りたいだけで新しい変更セットを追加したくないのに、更新されたときはいつでも適用したいときがなんどもあるでしょう。このよい例はストアドプロシージャに関するものです。ストアドプロシージャの全体をコピーして新しい変更セットを作るたび、とても長い変更ログが無駄に終わるだけでなく、ソースコード管理システムのマージや差分の機能を失うことになるのです。代わりに、runOnChange = “true” 属性を変更セットにあるストアドプロシージャのテキストにつけましょう。そのストアドプロシージャは、内容が変更されたときだけ再作成されるようになります。
簡単に言うと、changesetを変更しチェックサムが変わった場合に、前者のデフォルトの場合(runOnChange=false
)はエラー、runOnChange=true
の場合は再度実行という動作の違いがあるようです。
書いてある通りなのですが実際に動作させながら検証してみます。
デフォルトの場合
以下のPersonドメインがあるとします。
class Person { String name }
このドメインに対するchangelogを以下のように作成します。
databaseChangeLog = { changeSet(author: "yamkazu (generated)", id: "create-person") { createTable(tableName: "person") { column(name: "id", type: "int8") { constraints(nullable: "false", primaryKey: "true", primaryKeyName: "personPK") } column(name: "version", type: "int8") { constraints(nullable: "false") } column(name: "name", type: "varchar(255)") { constraints(nullable: "false") } } } }
この状態で一度dbm-update
コマンドを使用してデータベースと同期しておきます。同期が成功すると上のchangesetを反映した履歴がdatabasechangelogが書き込まれます。
devDb=# select id, md5sum from databasechangelog; id | md5sum ---------------+------------------------------------ create-person | 3:c2a46c4edd51cd911c9dced0a8fcabe3
changeSetの内容を元にmd5sum
が書き込まれています。この状態でほど作成したchangeSetのname
カラムの型をvarchar(255)
からtext
に変更します。
databaseChangeLog = { changeSet(author: "yamkazu (generated)", id: "create-person") { createTable(tableName: "person") { ... column(name: "name", type: "text") { constraints(nullable: "false") } } } }
同じIDにもかかわらずchangeSetの中身が書き換えられた状態です。この状態でdbm-validate
コマンドを実行します。
grails> dbm-validate | Starting dbm-validate for database test @ jdbc:postgresql://localhost:5432/devDb Validation Error: 1 change sets have changed since they were ran against the database changelog-0.1.groovy::create-person::yamkazu (generated)
このようにchangeSetの中身が変わりチェックサムがことなるためエラーとなります。dbm-update
などの同期処理においても必ず最初にこのvalidateが行われるため、同様にエラーになります(1.3.2までこのエラーがdbm-validate
コマンド以外では握りつぶされていましたが1.3.3で修正される予定です)。
text
への変更をvarchar(255)
に戻して、次はドメインに変更を加えてみます。
class Person { String name Integer age }
age
のプロパティを追加しました。このカラムを追加するchangesetを以下のように作成します。
databaseChangeLog = { ... changeSet(author: "yamkazu (generated)", id: "add-age") { addColumn(tableName: "person") { column(name: "age", type: "int4") { constraints(nullable: "false") } } } }
再度dbm-update
コマンドを使用してデータベースと同期してします。databasechangelogは以下のようになります。
devDb=# select id, md5sum from databasechangelog; id | md5sum ---------------+------------------------------------ create-person | 3:c2a46c4edd51cd911c9dced0a8fcabe3 add-age | 3:2e304646a134a175a22769177941c06a
すでにデータベースに反映が完了して、changesetのcreate-person
とadd-age
を正規化して一つにまとめたいと考えたとします。
databaseChangeLog = { changeSet(author: "yamkazu (generated)", id: "create-person") { createTable(tableName: "person") { column(name: "id", type: "int8") { constraints(nullable: "false", primaryKey: "true", primaryKeyName: "personPK") } column(name: "version", type: "int8") { constraints(nullable: "false") } column(name: "name", type: "varchar(255)") { constraints(nullable: "false") } column(name: "age", type: "int4") { constraints(nullable: "false") } } } }
create-person
のchangesetにage
を追加し、add-age
は削除しました。この状態でdbm-update
を実施すると当然エラーになります。チャックサムが異なるためです。
Validation Error:
1 change sets have changed since they were ran against the database
changelog-0.1.groovy::create-person::yamkazu (generated)
特にデータベースへ反映を行うわけではないがchangesetだけ正規化するとこのような状態になります。この状態を解消するには一度dbm-clear-checksum
を実行します。
grails> dbm-clear-checksums
databasechangelogは以下のようになります。
devDb=# select id, md5sum from databasechangelog; id | md5sum ---------------+-------- create-person | add-age |
このようにmd5sum
の値がクリアされます。この状態で再度dbm-update
を実施するとdatabasechangelogは以下のようになります。
devDb=# select id, md5sum from databasechangelog order by orderexecuted ; id | md5sum ---------------+------------------------------------ create-person | 3:393ba7bc4288b805a68f49b8c3f3f3ee add-age |
今度はエラーとならずにcreate-person
に新たなハッシュ値が書き込まれました。
ちなみにこの時にdbm-rollback-count-sql 1
を実行すると以下になります。
grails> dbm-rollback-count-sql 1 DROP TABLE person; DELETE FROM databasechangelog WHERE ID='create-person' AND AUTHOR='yamkazu (generated)' AND FILENAME='changelog-0.1.groovy';
どうも実行のsqlを見ているとmd5sum
がnullのレコードを対象として動作するようです。少し注意が必要です。
このsqlでロールバックするとadd-age
が残ってしまいますが、特に残っていても害はなさそうですが気になります。dbm-update
のオプションなどで再度チェックサム書いたあとに浮いたレコード削除してくれてもいいようなきもしますが、そんな機能は今のところないようです。
runOnChange=true の場合
runOnChange=true
はchangeSetの属性に設定します。注意点としては当たり前ですがrunOnChange=true
で再実行された際に問題なく動作するクエリを書くことです。
changeSet(author: "yamkazu (generated)", id: "create-one-function", runOnChange: true) { sql(""" | CREATE OR REPLACE FUNCTION one() RETURNS integer | AS 'SELECT 1 AS RESULT;' | LANGUAGE SQL; """.stripMargin()) }
CREATE OR REPLACE
というふうに宣言することで何度実行しても問題ありません。これでfunctionの中の実装が変わると再度実行されます。