packages feed

ephemeral-pg-0.1.0.0: src/EphemeralPg/Dump.hs

-- | Database dump and restore functionality.
--
-- This module provides wrappers around pg_dump and psql for
-- creating and restoring SQL dumps of ephemeral databases.
--
-- = Example Usage
--
-- @
-- result <- Pg.'with' $ \\db -> do
--   -- Set up some test data
--   runMigrations db
--
--   -- Create a dump
--   dumpResult <- Pg.'dump' db \"/path/to/dump.sql\"
--
--   -- Later, restore the dump to a new database
--   Pg.'with' $ \\newDb -> do
--     Pg.'restore' newDb \"/path/to/dump.sql\"
-- @
module EphemeralPg.Dump
  ( -- * Dump Operations
    dump,
    dumpToText,

    -- * Restore Operations
    restore,
    restoreFromText,

    -- * Configuration
    DumpFormat (..),
    DumpOptions (..),
    defaultDumpOptions,
  )
where

import Control.Exception (SomeException, try)
import Data.ByteString.Lazy qualified as LBS
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import EphemeralPg.Database (Database (..))
import EphemeralPg.Process (findExecutable)
import System.Exit (ExitCode (..))
import System.Process.Typed
  ( byteStringOutput,
    proc,
    readProcess,
    setEnv,
    setStderr,
    setStdout,
  )

-- | Output format for pg_dump.
data DumpFormat
  = -- | Plain SQL text format
    DumpPlain
  | -- | Custom archive format (for pg_restore)
    DumpCustom
  | -- | Directory format
    DumpDirectory
  | -- | Tar archive format
    DumpTar
  deriving stock (Eq, Show)

-- | Options for pg_dump.
data DumpOptions = DumpOptions
  { -- | Output format (default: DumpPlain)
    dumpFormat :: DumpFormat,
    -- | Include CREATE DATABASE command
    dumpCreateDb :: Bool,
    -- | Schema only (no data)
    dumpSchemaOnly :: Bool,
    -- | Data only (no schema)
    dumpDataOnly :: Bool,
    -- | Include specific tables (empty = all)
    dumpTables :: [Text],
    -- | Exclude specific tables
    dumpExcludeTables :: [Text]
  }
  deriving stock (Eq, Show)

-- | Default dump options (plain SQL, all tables, schema + data).
defaultDumpOptions :: DumpOptions
defaultDumpOptions =
  DumpOptions
    { dumpFormat = DumpPlain,
      dumpCreateDb = False,
      dumpSchemaOnly = False,
      dumpDataOnly = False,
      dumpTables = [],
      dumpExcludeTables = []
    }

-- | Dump a database to a file.
dump :: Database -> FilePath -> DumpOptions -> IO (Either Text ())
dump db outputPath opts = do
  mPgDump <- findExecutable "pg_dump"
  case mPgDump of
    Nothing -> pure $ Left "pg_dump not found in PATH"
    Just pgDumpPath -> do
      let args = buildDumpArgs db opts <> ["-f", T.pack outputPath]
          env = buildEnv db
          processConfig =
            proc pgDumpPath (map T.unpack args)
              & setStdout byteStringOutput
              & setStderr byteStringOutput
              & setEnv env
      result <- try $ readProcess processConfig
      case result of
        Left (ex :: SomeException) ->
          pure $ Left $ "pg_dump failed: " <> T.pack (show ex)
        Right (exitCode, _stdout, stderr) ->
          case exitCode of
            ExitSuccess -> pure $ Right ()
            ExitFailure code ->
              pure $
                Left $
                  "pg_dump failed with code "
                    <> T.pack (show code)
                    <> ": "
                    <> T.decodeUtf8Lenient (LBS.toStrict stderr)
  where
    (&) = flip ($)

-- | Dump a database and return the dump as text.
--
-- Only works with DumpPlain format.
dumpToText :: Database -> DumpOptions -> IO (Either Text Text)
dumpToText db opts = do
  if dumpFormat opts /= DumpPlain
    then pure $ Left "dumpToText only works with DumpPlain format"
    else do
      mPgDump <- findExecutable "pg_dump"
      case mPgDump of
        Nothing -> pure $ Left "pg_dump not found in PATH"
        Just pgDumpPath -> do
          let args = buildDumpArgs db opts
              env = buildEnv db
              processConfig =
                proc pgDumpPath (map T.unpack args)
                  & setStdout byteStringOutput
                  & setStderr byteStringOutput
                  & setEnv env
          result <- try $ readProcess processConfig
          case result of
            Left (ex :: SomeException) ->
              pure $ Left $ "pg_dump failed: " <> T.pack (show ex)
            Right (exitCode, stdout, stderr) ->
              case exitCode of
                ExitSuccess -> pure $ Right $ T.decodeUtf8Lenient $ LBS.toStrict stdout
                ExitFailure code ->
                  pure $
                    Left $
                      "pg_dump failed with code "
                        <> T.pack (show code)
                        <> ": "
                        <> T.decodeUtf8Lenient (LBS.toStrict stderr)
  where
    (&) = flip ($)

-- | Restore a database from a dump file.
restore :: Database -> FilePath -> IO (Either Text ())
restore db inputPath = do
  mPsql <- findExecutable "psql"
  case mPsql of
    Nothing -> pure $ Left "psql not found in PATH"
    Just psqlPath -> do
      let args =
            [ "-h",
              T.pack $ dbSocketDirectory db,
              "-p",
              T.pack $ show $ dbPort db,
              "-U",
              dbUser db,
              "-d",
              dbDatabaseName db,
              "-f",
              T.pack inputPath,
              "-v",
              "ON_ERROR_STOP=1"
            ]
          env = buildEnv db
          processConfig =
            proc psqlPath (map T.unpack args)
              & setStdout byteStringOutput
              & setStderr byteStringOutput
              & setEnv env
      result <- try $ readProcess processConfig
      case result of
        Left (ex :: SomeException) ->
          pure $ Left $ "psql restore failed: " <> T.pack (show ex)
        Right (exitCode, _stdout, stderr) ->
          case exitCode of
            ExitSuccess -> pure $ Right ()
            ExitFailure code ->
              pure $
                Left $
                  "psql restore failed with code "
                    <> T.pack (show code)
                    <> ": "
                    <> T.decodeUtf8Lenient (LBS.toStrict stderr)
  where
    (&) = flip ($)

-- | Restore a database from SQL text.
restoreFromText :: Database -> Text -> IO (Either Text ())
restoreFromText db sqlText = do
  mPsql <- findExecutable "psql"
  case mPsql of
    Nothing -> pure $ Left "psql not found in PATH"
    Just psqlPath -> do
      let args =
            [ "-h",
              T.pack $ dbSocketDirectory db,
              "-p",
              T.pack $ show $ dbPort db,
              "-U",
              dbUser db,
              "-d",
              dbDatabaseName db,
              "-c",
              sqlText,
              "-v",
              "ON_ERROR_STOP=1"
            ]
          env = buildEnv db
          processConfig =
            proc psqlPath (map T.unpack args)
              & setStdout byteStringOutput
              & setStderr byteStringOutput
              & setEnv env
      result <- try $ readProcess processConfig
      case result of
        Left (ex :: SomeException) ->
          pure $ Left $ "psql restore failed: " <> T.pack (show ex)
        Right (exitCode, _stdout, stderr) ->
          case exitCode of
            ExitSuccess -> pure $ Right ()
            ExitFailure code ->
              pure $
                Left $
                  "psql restore failed with code "
                    <> T.pack (show code)
                    <> ": "
                    <> T.decodeUtf8Lenient (LBS.toStrict stderr)
  where
    (&) = flip ($)

-- | Build pg_dump command line arguments.
buildDumpArgs :: Database -> DumpOptions -> [Text]
buildDumpArgs db opts =
  [ "-h",
    T.pack $ dbSocketDirectory db,
    "-p",
    T.pack $ show $ dbPort db,
    "-U",
    dbUser db,
    "-d",
    dbDatabaseName db
  ]
    <> formatArg
    <> createDbArg
    <> schemaOnlyArg
    <> dataOnlyArg
    <> tableArgs
    <> excludeTableArgs
  where
    formatArg = case dumpFormat opts of
      DumpPlain -> ["-Fp"]
      DumpCustom -> ["-Fc"]
      DumpDirectory -> ["-Fd"]
      DumpTar -> ["-Ft"]

    createDbArg =
      if dumpCreateDb opts then ["-C"] else []

    schemaOnlyArg =
      if dumpSchemaOnly opts then ["-s"] else []

    dataOnlyArg =
      if dumpDataOnly opts then ["-a"] else []

    tableArgs =
      concatMap (\t -> ["-t", t]) (dumpTables opts)

    excludeTableArgs =
      concatMap (\t -> ["-T", t]) (dumpExcludeTables opts)

-- | Build environment variables for pg commands.
buildEnv :: Database -> [(String, String)]
buildEnv db =
  [("PGPASSWORD", T.unpack pw) | Just pw <- [dbPassword db]]