packages feed

morph (empty) → 0.1.0.0

raw patch · 7 files changed

+336/−0 lines, 7 filesdep +aesondep +basedep +bytestringsetup-changed

Dependencies added: aeson, base, bytestring, directory, filepath, morph, optparse-applicative, postgresql-simple, text, yaml

Files

+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2016, Thomas Feron++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Thomas Feron nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Main.hs view
@@ -0,0 +1,10 @@+import Morph.Config+import Morph.Migrator+import Morph.Options++main :: IO ()+main = do+  opts   <- getOptions+  config <- readConfigOrDie opts+  withConnection config $ \conn -> do+    migrate conn $ optsMigrationsDirectory opts
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ morph.cabal view
@@ -0,0 +1,48 @@+-- Initial morph.cabal generated by cabal init.  For further documentation,+--  see http://haskell.org/cabal/users-guide/++name:                  morph+version:               0.1.0.0+synopsis:              A simple database migrator for PostgreSQL+description:           Morph is a tool to migrate your PostgreSQL databases+                       safely which supports rollbacks.+license:               BSD3+license-file:          LICENSE+author:                Thomas Feron+maintainer:            tho.feron@gmail.com+build-type:            Simple+cabal-version:         >=1.10++source-repository head+  type:                darcs+  location:            http://darcs.redspline.com/morph+  tag:                 0.1.0.0++library+  hs-source-dirs:      src+  ghc-options:         -Wall+  default-language:    Haskell2010++  default-extensions:  OverloadedStrings++  exposed-modules:     Morph.Config+                       Morph.Migrator+                       Morph.Options++  build-depends:       base >=4.8 && <4.9+                     , optparse-applicative ==0.12.*+                     , aeson+                     , yaml+                     , postgresql-simple+                     , bytestring+                     , text+                     , directory+                     , filepath++executable morph+  main-is:             Main.hs+  hs-source-dirs:      .+  ghc-options:         -Wall+  default-language:    Haskell2010+  build-depends:       base >=4.8 && <4.9+                     , morph
+ src/Morph/Config.hs view
@@ -0,0 +1,88 @@+module Morph.Config+  ( readConfigOrDie+  , withConnection+  ) where++import           Control.Exception++import           Data.Maybe+import qualified Data.Aeson                 as J+import qualified Data.Aeson.Types           as J hiding (Options)+import qualified Data.Yaml                  as Y+import qualified Data.ByteString.Lazy.Char8 as BSL+import qualified Data.Text                  as T++import           System.Exit+import           System.IO++import           Database.PostgreSQL.Simple++import           Morph.Options++data Config = Config+  { configUsername :: String+  , configPassword :: String+  , configHostname :: String+  , configPort     :: Integer+  , configName     :: String+  }++instance J.FromJSON Config where+  parseJSON = J.withObject "Config" $ \obj -> Config+    <$> obj J..: "username"+    <*> obj J..: "password"+    <*> obj J..: "hostname"+    <*> obj J..: "port"+    <*> obj J..: "name"++parseJSONConfig :: [T.Text] -> J.Value -> J.Parser Config+parseJSONConfig [] = J.parseJSON+parseJSONConfig (key : keys) = J.withObject "Parent object" $ \obj -> do+  val <- obj J..: key+  parseJSONConfig keys val++parseYAMLConfig :: [T.Text] -> Y.Value -> Y.Parser Config+parseYAMLConfig [] val = Y.parseJSON val+parseYAMLConfig (key : keys) (Y.Object obj) = do+  val <- obj Y..: key+  parseYAMLConfig keys val+parseYAMLConfig _ _ = fail "Object expected"++readConfigOrDie :: Options -> IO Config+readConfigOrDie opts = do+  contents <- BSL.readFile $+    fromMaybe (if optsJSONConfig opts then "config.json" else "config.yml")+              (optsConfigFile opts)++  let eConfig+        | optsJSONConfig opts = do+            val <- J.eitherDecode contents+            J.parseEither (parseJSONConfig (optsKeysPath opts)) val+        | otherwise = do+            val <- Y.decodeEither $ BSL.toStrict contents+            Y.parseEither (parseYAMLConfig (optsKeysPath opts)) val++  case eConfig of+    Right config -> return config+    Left err -> do+      hPutStrLn stderr $ "Can't read the config file: " ++ err+      exitFailure++createConn :: Config -> IO Connection+createConn config = connect ConnectInfo+  { connectHost     = configHostname config+  , connectUser     = configUsername config+  , connectPassword = configPassword config+  , connectDatabase = configName     config+  , connectPort     = fromInteger $ configPort config+  }++destroyConn :: Connection -> IO ()+destroyConn = close++withConnection :: Config -> (Connection -> IO a) -> IO a+withConnection config f = do+  conn <- createConn config+  res <- catch (f conn) $ \e -> destroyConn conn >> throw (e :: SomeException)+  destroyConn conn+  return res
+ src/Morph/Migrator.hs view
@@ -0,0 +1,122 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleInstances #-}++module Morph.Migrator+  ( migrate+  ) where++import Control.Monad++import Data.List+import Data.Maybe+import Data.Monoid+import Data.String++import System.Directory+import System.FilePath+import System.IO++import Database.PostgreSQL.Simple+import Database.PostgreSQL.Simple.FromRow++-- | A migration can either be read from file and contain both sides or from the+-- database and contain only the down side.+data MigrationType = Full | Rollback++type family MigrationSQL (a :: MigrationType) :: * where+  MigrationSQL 'Full     = (Query, String)+  MigrationSQL 'Rollback = Query++data Migration :: MigrationType -> * where+  Migration ::+    { migrationIdentifier :: String+    , migrationSQL        :: MigrationSQL a+    } -> Migration a++createMigrationTable :: Connection -> IO ()+createMigrationTable conn = void $ execute_ conn+  "CREATE TABLE IF NOT EXISTS migrations (\+  \  id           varchar PRIMARY KEY CHECK (id <> ''),\+  \  rollback_sql text CHECK (rollback_sql <> '')\+  \);"++listDone :: Connection -> IO [Migration 'Rollback]+listDone conn = do+  pairs <- query_ conn "SELECT id, rollback_sql FROM migrations ORDER BY id ASC"+  return $ flip map pairs $ \(identifier, mSQL) -> Migration+    { migrationIdentifier = identifier+    , migrationSQL        = maybe "" fromString mSQL+    }++listGoals :: FilePath -> IO [Migration 'Full]+listGoals dir = do+    allNames <- sort <$> getDirectoryContents dir+    let upNames     = filter (".up.sql"   `isSuffixOf`) allNames+        downNames   = filter (".down.sql" `isSuffixOf`) allNames++    forM upNames $ \upName -> do+      let identifier = extractIdentifier upName+      up   <- readMigrationFile upName+      down <- readDownMigrationFile downNames identifier+      return Migration+        { migrationIdentifier = identifier+        , migrationSQL        = (up, down)+        }++  where+    extractIdentifier :: FilePath -> String+    extractIdentifier = takeWhile (`elem` ("0123456789" :: String))++    readMigrationFile :: FilePath -> IO Query+    readMigrationFile path = do+      contents <- readFile $ dir </> path+      return $ fromString contents++    readDownMigrationFile :: [FilePath] -> String -> IO String+    readDownMigrationFile paths identifier =+      case find ((==identifier) . extractIdentifier) paths of+        Nothing -> return $+          "RAISE EXCEPTION 'No rollback migration found for "+          <> fromString identifier <> "';"+        Just path -> readFile $ dir </> path++rollbackMigration :: Connection -> Migration 'Rollback -> IO ()+rollbackMigration conn migration = do+  hPutStrLn stderr $+    "Rollbacking migration " ++ migrationIdentifier migration ++ " ..."+  void $ execute_ conn $ migrationSQL migration+  void $ execute conn "DELETE FROM migrations WHERE id = ?" $+    Only $ migrationIdentifier migration++doMigration :: Connection -> Migration 'Full -> IO ()+doMigration conn migration = do+  hPutStrLn stderr $+    "Running migration " ++ migrationIdentifier migration ++ " ..."+  let (up, down) = migrationSQL migration+  void $ execute_ conn up+  void $ execute conn "INSERT INTO migrations (id, rollback_sql) VALUES (?, ?)"+                 (migrationIdentifier migration, down)++migrate :: Connection -> FilePath -> IO ()+migrate conn dir = do+  createMigrationTable conn++  doneMigrations <- listDone  conn+  goalMigrations <- listGoals dir++  let doneIdentifiers = map migrationIdentifier doneMigrations+      goalIdentifiers = map migrationIdentifier goalMigrations++      toRollbackIdentifiers = doneIdentifiers \\ goalIdentifiers+      toDoIdentifiers       = goalIdentifiers \\ doneIdentifiers++      toRollback = filter ((`elem` toRollbackIdentifiers) . migrationIdentifier)+                          doneMigrations+      toDo       = filter ((`elem` toDoIdentifiers)       . migrationIdentifier)+                          goalMigrations++  withTransaction conn $ do+    forM_ toRollback $ rollbackMigration conn+    forM_ toDo       $ doMigration       conn
+ src/Morph/Options.hs view
@@ -0,0 +1,36 @@+module Morph.Options+  ( Options(..)+  , getOptions+  ) where++import qualified Data.Text as T++import           Options.Applicative++data Options = Options+  { optsConfigFile          :: Maybe FilePath+  , optsKeysPath            :: [T.Text]+  , optsMigrationsDirectory :: FilePath+  , optsJSONConfig          :: Bool+  }++optionsParser :: Parser Options+optionsParser = Options+  <$> option (Just <$> str)+             (short 'c' <> long "config" <> metavar "PATH"+              <> value Nothing <> help "Path to the config file.")+  <*> option ((T.splitOn "." . T.pack) <$> str)+             (short 'p' <> long "path" <> metavar "KEY1[.KEY2[...]]"+              <> value [] <> help "The keys to traverse in the JSON to find\+                                  \ the database connection info.")+  <*> strOption (short 'd' <> long "dir" <> metavar "PATH"+                 <> showDefault <> value "migrations"+                 <> help "Path to the directory containing migrations.")+  <*> flag False True (short 'j' <> long "json"+                       <> help "Read config file as JSON.")++getOptions :: IO Options+getOptions = execParser $ info (helper <*> optionsParser) $+  fullDesc+  <> progDesc "Migrator for PostgreSQL databases with support for rollbacks"+  <> footer "This program is licensed under the BSD-3 license."