2015年8月29日土曜日

[haskell][persistent][sqlite] Persistentパッケージのmigration機能のまとめ

HaskellでDB操作ができるPersistentパッケージの紹介をしましたが、このエントリではPersistentパッケージが提供しているmigration機能をまとめておきます。
DBを作って運用していると、機能追加や仕様変更に伴いスキーマ変更が必要になるケースが多々あります。このようなケースにおいてPersistentのmigration機能がどれくらい使えるのかを調べた結果です。

基本:

Persistetのmigration機構は(保守的なルールに沿って)スキーマ変更をある程度まで自動で処理してくれます。
ロードしたDB内のテーブル情報と、コードで定義されたEntity Definition(テーブル定義)を比較し、以下のケースにおいてスキーマの変更を行います。
  • カラムの型を変更した場合:
    • ただし、値の変換ができない場合には、DBによって拒否されることになります。
  • カラムを追加した場合:
    • ただし、追加したカラムにNOT NULL制約がある場合は、デフォルト値は設定されず、エラーとなります。
  • カラムのNOT NULL制約を外してNULL許容にした場合:
    • 変換を実行します。
    • 逆にNULL許容カラムにNON NULL制約に変更するケースの結果は、DBの状態に依存します(NULL値が存在しなければ成功、存在していると失敗)。
  • 新規Entity(テーブル)が追加された場合
Persistentは以下のケースには対応していません。
  • フィールド、Entith(テーブル)のリネーム
    • 変更前の名前と変更後の名前の対応関係がわからないので…。
  • フィールドの削除
    • データ喪失につながるので、デフォルトではフィールド削除はエラーとなります。ですが、runMigration()の代わりにrunMigrationUnsafe()を呼ぶことで強制的に削除することは可能です(もちろん非推奨)。

実験:

Migration対象のDBを以下のコードでold.dbという名前で生成します。このコードで生成されたold.dbを、新しいスキーマにmigrateすると、結果がどのようになるかをまとめておきます。
{-# LANGUAGE EmptyDataDecls             #-}
{-# LANGUAGE FlexibleContexts           #-}
{-# LANGUAGE GADTs                      #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE OverloadedStrings          #-}
{-# LANGUAGE QuasiQuotes                #-}
{-# LANGUAGE TemplateHaskell            #-}
{-# LANGUAGE TypeFamilies               #-}
import Database.Persist
import Database.Persist.TH
import Database.Persist.Sqlite
import Control.Monad.IO.Class (liftIO)

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
    name String
    age Int
    deriving Show
|]

main :: IO ()
main = runSqlite "old.db" $ do
    -- this line added: that's it!
    runMigration migrateAll
    michaelId <- insert $ Person "Michael" 26
    michael <- get michaelId
    liftIO $ print michael


以下、いくつかのスキーマ変更を実際に試してみた結果です。
  • NULL許容カラムの追加:OK
    • コードの変更点
    • Person
          name String
          age Int
          address String Maybe  -- add new NULL-able column
          deriving Show
      |]
      
          michaelId <- insert $ Person "Michael" 26 (Just "Tokyo")
      
    • 実行結果ログ
    • Migrating: CREATE TEMP TABLE "person_backup"("id" INTEGER PRIMARY KEY,"name" VARCHAR NOT NULL,"age" INTEGER NOT NULL,"address" VARCHAR NULL)
      Migrating: INSERT INTO "person_backup"("id","name","age") SELECT "id","name","age" FROM "person"
      Migrating: DROP TABLE "person"
      Migrating: CREATE TABLE "person"("id" INTEGER PRIMARY KEY,"name" VARCHAR NOT NULL,"age" INTEGER NOT NULL,"address" VARCHAR NULL)
      Migrating: INSERT INTO "person" SELECT "id","name","age","address" FROM "person_backup"
      Migrating: DROP TABLE "person_backup"
      Just (Person {personName = "Michael", personAge = 26, personAddress = Just "Tokyo"})
      
  • NON NULL制約のカラム追加:NG
    • コード変更点
    • share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
      Person
          name String
          age Int
          address String  -- add new NON NULL column
          deriving Show
      |]
      
          michaelId <- insert $ Person "Michael" 26 "Tokyo"
      
    • 実行結果ログ
    • Migrating: CREATE TEMP TABLE "person_backup"("id" INTEGER PRIMARY KEY,"name" VARCHAR NOT NULL,"age" INTEGER NOT NULL,"address" VARCHAR NOT NULL)
      Migrating: INSERT INTO "person_backup"("id","name","age") SELECT "id","name","age" FROM "person"
      add_column: user error (SQLite3 returned ErrorConstraint while attempting to perform step.)
      
  • カラムの型の変更:OK
    • コード変更点
    • Person
          name String
          age String  -- change type from Int to String
          deriving Show
      |]
      
          michaelId <- insert $ Person "Michael" "26"
      
    • 実行結果ログ
    • Migrating: CREATE TEMP TABLE "person_backup"("id" INTEGER PRIMARY KEY,"name" VARCHAR NOT NULL,"age" VARCHAR NOT NULL)
      Migrating: INSERT INTO "person_backup"("id","name","age") SELECT "id","name","age" FROM "person"
      Migrating: DROP TABLE "person"
      Migrating: CREATE TABLE "person"("id" INTEGER PRIMARY KEY,"name" VARCHAR NOT NULL,"age" VARCHAR NOT NULL)
      Migrating: INSERT INTO "person" SELECT "id","name","age" FROM "person_backup"
      Migrating: DROP TABLE "person_backup"
      Just (Person {personName = "Michael", personAge = "26"})
      
ログを見る限り以下の手順でmigrationを行っています。参照したYesodサイトに記載がありましたがSQLiteではALTER TABLEの機能が足りないためだと思われます。
  1. 古いスキーマでperson_backupテーブルを生成
  2. person_backupテーブルにオリジナルのpersonテーブルの内容をコピー
  3. personテーブルを破棄
  4. 新しいスキーマでpersonテーブルを生成
  5. person_backupの内容をpersonにコピー
  6. person_backupテーブルを破棄

宿題:

自分の用途では、NON NULL制約のカラムを追加し、既存レコードの値にはデフォルト値を埋めた状態にしたかったのですがPersistent備え付けの機能ではできないようです。
migration処理はTemplateHaskellによって生成されたコードで実行されているらしいので、そのあたりを弄ればなんとかなるのかも。
カラム名の変更も新スキーマのカラムと旧スキーマのカラム名の対応をmigration処理にうまく伝えることができれば、なんとかなりそうな気がします。このあたりは今後の課題として、方法がわかったらまたblogにまとめます。

参考:

0 件のコメント:

コメントを投稿