pg-migrate-embed-1.1.0.0: src/Database/PostgreSQL/Migrate/Embed/Manifest.hs
module Database.PostgreSQL.Migrate.Embed.Manifest
( manifestFormatVersion,
ManifestError (..),
checkMigrationManifest,
embedMigrationManifest,
validateManifestEntry,
byteStringExpression,
)
where
import Control.Exception (IOException)
import Control.Exception qualified as Exception
import Data.Bifunctor (first)
import Data.ByteString qualified as ByteString
import Data.ByteString.Internal qualified as ByteString.Internal
import Data.Foldable (toList, traverse_)
import Data.List qualified as List
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Language.Haskell.TH
( Exp (..),
Lit (..),
Q,
)
import Language.Haskell.TH.Lib qualified as TH.Lib
import Language.Haskell.TH.Syntax qualified as TH.Syntax
import System.Directory qualified as Directory
import System.FilePath qualified as FilePath
-- | Supported ordered-manifest contract version.
manifestFormatVersion :: Int
manifestFormatVersion = 1
-- | Exact manifest syntax, path, membership, or SQL validation failure.
data ManifestError
= ManifestIoError !FilePath !Text.Text
| ManifestInvalidUtf8 !FilePath !Text.Text
| ManifestByteOrderMark !FilePath
| EmptyManifest
| BlankManifestLine !Int
| CommentManifestLine !Int
| ManifestEntryHasSurroundingWhitespace !Int !FilePath
| AbsoluteManifestEntry !Int !FilePath
| ParentTraversalManifestEntry !Int !FilePath
| NestedManifestEntry !Int !FilePath
| NonSqlManifestEntry !Int !FilePath
| EmptySqlBasename !Int
| DuplicateManifestEntry !FilePath !Int !Int
| MissingManifestFile !FilePath
| UnlistedSqlFiles ![FilePath]
deriving stock (Eq, Show)
-- | Validate a manifest and return ordered exact SQL bytes at runtime.
checkMigrationManifest ::
FilePath ->
IO (Either ManifestError (NonEmpty (FilePath, ByteString.ByteString)))
checkMigrationManifest manifestPath = do
manifestResult <- tryIOException (ByteString.readFile manifestPath)
case manifestResult of
Left err -> pure (Left (ManifestIoError manifestPath (Text.pack (show err))))
Right manifestBytes ->
case parseManifest manifestPath manifestBytes of
Left err -> pure (Left err)
Right entries -> checkManifestFiles manifestPath entries
-- | Validate and embed an ordered migration manifest at compile time.
--
-- On GHC 9.12, modules using this splice should load
-- @Database.PostgreSQL.Migrate.Embed.RecompilePlugin@ with a module-local
-- @OPTIONS_GHC -fplugin=...@ pragma. The plugin makes additions and removals in the
-- manifest directory observable even though that compiler can register only existing
-- files as Template Haskell dependencies.
embedMigrationManifest :: FilePath -> Q Exp
embedMigrationManifest inputPath = do
manifestPath <- TH.Syntax.makeRelativeToProject inputPath
TH.Syntax.addDependentFile manifestPath
result <- TH.Syntax.runIO (checkMigrationManifest manifestPath)
case result of
Left err -> fail ("invalid pg-migrate manifest: " <> show err)
Right entries -> do
let directory = FilePath.takeDirectory manifestPath
traverse_
(TH.Syntax.addDependentFile . (directory FilePath.</>) . fst)
entries
pure (nonEmptyExpression entries)
parseManifest :: FilePath -> ByteString.ByteString -> Either ManifestError (NonEmpty FilePath)
parseManifest manifestPath manifestBytes
| utf8ByteOrderMark `ByteString.isPrefixOf` manifestBytes =
Left (ManifestByteOrderMark manifestPath)
| otherwise = do
manifestText <-
first
(ManifestInvalidUtf8 manifestPath . Text.pack . show)
(Text.Encoding.decodeUtf8' manifestBytes)
let numberedLines = zip [1 ..] (normalizeLineEnding <$> Text.lines manifestText)
entries <- traverse validateManifestLine numberedLines
nonEmptyEntries <-
case entries of
firstEntry : remainingEntries -> Right (firstEntry :| remainingEntries)
[] -> Left EmptyManifest
validateDuplicates nonEmptyEntries
utf8ByteOrderMark :: ByteString.ByteString
utf8ByteOrderMark = ByteString.pack [0xEF, 0xBB, 0xBF]
normalizeLineEnding :: Text.Text -> Text.Text
normalizeLineEnding = Text.dropWhileEnd (== '\r')
validateManifestLine :: (Int, Text.Text) -> Either ManifestError FilePath
validateManifestLine (lineNumber, entryText)
| Text.null entryText = Left (BlankManifestLine lineNumber)
| "#" `Text.isPrefixOf` Text.stripStart entryText =
Left (CommentManifestLine lineNumber)
| "--" `Text.isPrefixOf` Text.stripStart entryText =
Left (CommentManifestLine lineNumber)
| Text.strip entryText /= entryText =
Left (ManifestEntryHasSurroundingWhitespace lineNumber entry)
| FilePath.isAbsolute entry = Left (AbsoluteManifestEntry lineNumber entry)
| ".." `elem` FilePath.splitDirectories entry =
Left (ParentTraversalManifestEntry lineNumber entry)
| FilePath.takeFileName entry /= entry = Left (NestedManifestEntry lineNumber entry)
| FilePath.takeExtension entry /= ".sql" = Left (NonSqlManifestEntry lineNumber entry)
| null (FilePath.dropExtension entry) = Left (EmptySqlBasename lineNumber)
| otherwise = Right entry
where
entry = Text.unpack entryText
validateManifestEntry :: FilePath -> Either ManifestError FilePath
validateManifestEntry entry = validateManifestLine (1, Text.pack entry)
validateDuplicates :: NonEmpty FilePath -> Either ManifestError (NonEmpty FilePath)
validateDuplicates entries = go [] (zip [1 ..] (toList entries))
where
go _ [] = Right entries
go seen ((lineNumber, entry) : remaining) =
case List.lookup entry seen of
Just firstLine -> Left (DuplicateManifestEntry entry firstLine lineNumber)
Nothing -> go ((entry, lineNumber) : seen) remaining
checkManifestFiles ::
FilePath ->
NonEmpty FilePath ->
IO (Either ManifestError (NonEmpty (FilePath, ByteString.ByteString)))
checkManifestFiles manifestPath entries = do
let directory = FilePath.takeDirectory manifestPath
listedEntries = toList entries
directoryResult <- tryIOException (Directory.listDirectory directory)
case directoryResult of
Left err -> pure (Left (ManifestIoError directory (Text.pack (show err))))
Right directoryEntries ->
case List.sort
[ entry
| entry <- directoryEntries,
FilePath.takeExtension entry == ".sql",
entry `notElem` listedEntries
] of
unlisted@(_ : _) -> pure (Left (UnlistedSqlFiles unlisted))
[] -> readManifestFiles directory entries
readManifestFiles ::
FilePath ->
NonEmpty FilePath ->
IO (Either ManifestError (NonEmpty (FilePath, ByteString.ByteString)))
readManifestFiles directory entries = do
results <- traverse readEntry entries
pure (sequence results)
where
readEntry entry = do
let path = directory FilePath.</> entry
exists <- Directory.doesFileExist path
if not exists
then pure (Left (MissingManifestFile entry))
else do
result <- tryIOException (ByteString.readFile path)
pure $ case result of
Left err -> Left (ManifestIoError path (Text.pack (show err)))
Right bytes -> Right (entry, bytes)
nonEmptyExpression :: NonEmpty (FilePath, ByteString.ByteString) -> Exp
nonEmptyExpression (firstEntry :| remainingEntries) =
AppE
(AppE (ConE '(:|)) (entryExpression firstEntry))
(ListE (entryExpression <$> remainingEntries))
entryExpression :: (FilePath, ByteString.ByteString) -> Exp
entryExpression (entry, bytes) =
TupE
[ Just (LitE (StringL entry)),
Just (byteStringExpression bytes)
]
byteStringExpression :: ByteString.ByteString -> Exp
byteStringExpression bytes =
let (foreignPointer, offset, length_) = ByteString.Internal.toForeignPtr bytes
in AppE
( AppE
(VarE 'ByteString.Internal.unsafePackLenLiteral)
(LitE (IntegerL (fromIntegral length_)))
)
( LitE
( TH.Lib.bytesPrimL
( TH.Lib.mkBytes
foreignPointer
(fromIntegral offset)
(fromIntegral length_)
)
)
)
tryIOException :: IO value -> IO (Either IOException value)
tryIOException = Exception.try