pg-migrate-cli 1.0.0.0 → 1.1.0.0
raw patch · 20 files changed
+542/−169 lines, 20 filesdep ~pg-migratedep ~pg-migrate-embedPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: pg-migrate, pg-migrate-embed
API changes (from Hackage documentation)
- Database.PostgreSQL.Migrate.CLI: ExitSuccess :: ExitClass
+ Database.PostgreSQL.Migrate.CLI: CliInputError :: !Text -> CliError
+ Database.PostgreSQL.Migrate.CLI: ExitSucceeded :: ExitClass
- Database.PostgreSQL.Migrate.CLI: ExecutionOptions :: !LockWait -> !Maybe NominalDiffTime -> ExecutionOptions
+ Database.PostgreSQL.Migrate.CLI: ExecutionOptions :: !Maybe LockWait -> !Maybe (Maybe NominalDiffTime) -> ExecutionOptions
- Database.PostgreSQL.Migrate.CLI: [lockWait] :: ExecutionOptions -> !LockWait
+ Database.PostgreSQL.Migrate.CLI: [lockWait] :: ExecutionOptions -> !Maybe LockWait
- Database.PostgreSQL.Migrate.CLI: [statementTimeout] :: ExecutionOptions -> !Maybe NominalDiffTime
+ Database.PostgreSQL.Migrate.CLI: [statementTimeout] :: ExecutionOptions -> !Maybe (Maybe NominalDiffTime)
Files
- CHANGELOG.md +33/−0
- pg-migrate-cli.cabal +5/−7
- src/Database/PostgreSQL/Migrate/CLI/Handler.hs +118/−56
- src/Database/PostgreSQL/Migrate/CLI/Json.hs +29/−19
- src/Database/PostgreSQL/Migrate/CLI/Outcome.hs +3/−2
- src/Database/PostgreSQL/Migrate/CLI/Parser.hs +38/−22
- src/Database/PostgreSQL/Migrate/CLI/Text.hs +18/−21
- src/Database/PostgreSQL/Migrate/CLI/Types.hs +30/−2
- test/golden/help/check.txt +2/−2
- test/golden/help/new.txt +1/−1
- test/golden/help/repair.txt +6/−3
- test/golden/help/up.txt +6/−3
- test/golden/json/import.json +2/−1
- test/golden/json/repair.json +2/−1
- test/golden/json/up-cleanup.json +21/−0
- test/golden/json/up.json +2/−1
- test/integration/Main.hs +78/−12
- test/unit/Test/Handler.hs +80/−10
- test/unit/Test/Json.hs +23/−5
- test/unit/Test/Parser.hs +45/−1
CHANGELOG.md view
@@ -1,5 +1,38 @@ # Changelog +## 1.1.0.0 — 2026-07-13++Major release: this set changes public record field types, renames a public constructor,+removes an accidentally exposed module, and changes the `check` command syntax.++### Breaking changes++- Changed `ExecutionOptions` to represent absent lock and statement-timeout flags as+ optional overrides. Applications constructing commands directly must wrap explicit+ values in `Just`; `Nothing` now preserves `CliEnvironment` runner settings.+- Renamed `ExitSuccess` to `ExitSucceeded`, avoiding its collision with+ `System.Exit.ExitSuccess`.+- Changed `check MANIFEST` to `check --manifest PATH`, matching `new --manifest`.+- Stopped exposing the internal `PgMigrate.CLI.Prelude` module.+- Added `CliInputError` to the public `CliError` sum for command-input failures.+- Updated public report construction for the core package's new `cleanupIssues` fields and+ mandatory-primary `CleanupFailed` shape.++### Fixes and behavior changes++- Added explicit `--wait` and `--no-statement-timeout` overrides; absent execution flags no+ longer discard application-configured `RunOptions`.+- Reject `new --description` values containing control characters before any file is+ created, including for callers that construct `NewOptions` directly.+- Classify manifest IO failures from `check` and `new` as execution failures while keeping+ manifest validation failures as usage failures.+- Reject unknown inspection filters for `plan`, `list`, `status`, and `verify`; filter+ report issues as well as migration lists while retaining the full-report verification+ exit class.+- Share one checksum renderer between text and JSON output without changing rendered bytes.+- Render successful migration and repair cleanup observations in text and as an additive+ `cleanup_issues` array in JSON schema v1; history-import JSON exposes the same field.+ ## 1.0.0.0 — 2026-07-10 - Initial stable release of the reusable command tree, typed dispatcher, text output,
pg-migrate-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.8 name: pg-migrate-cli-version: 1.0.0.0+version: 1.1.0.0 synopsis: Reusable command parsers and renderers for pg-migrate category: Database maintainer: nadeem@gmail.com@@ -29,10 +29,7 @@ library import: common hs-source-dirs: src- exposed-modules:- Database.PostgreSQL.Migrate.CLI- PgMigrate.CLI.Prelude-+ exposed-modules: Database.PostgreSQL.Migrate.CLI other-modules: Database.PostgreSQL.Migrate.CLI.Handler Database.PostgreSQL.Migrate.CLI.Json@@ -40,6 +37,7 @@ Database.PostgreSQL.Migrate.CLI.Parser Database.PostgreSQL.Migrate.CLI.Text Database.PostgreSQL.Migrate.CLI.Types+ PgMigrate.CLI.Prelude build-depends: , aeson >=2.2 && <2.3@@ -48,8 +46,8 @@ , containers >=0.7 && <0.8 , hasql >=1.10 && <1.11 , optparse-applicative >=0.19 && <0.20- , pg-migrate >=1.0 && <1.1- , pg-migrate-embed >=1.0 && <1.1+ , pg-migrate >=1.1 && <1.2+ , pg-migrate-embed >=1.1 && <1.2 , text >=2.1 && <2.2 , time >=1.12 && <1.15
src/Database/PostgreSQL/Migrate/CLI/Handler.hs view
@@ -18,6 +18,7 @@ RunOptions, StatusReport (..), StoredMigration (..),+ VerificationIssue (..), VerificationReport (..), connectionProviderFromSettings, migrationFingerprint,@@ -33,6 +34,7 @@ import Database.PostgreSQL.Migrate.CLI.Types import Database.PostgreSQL.Migrate.Embed ( AuthoringError (..),+ ManifestError (..), checkMigrationManifest, newMigration, newMigrationOptions,@@ -41,8 +43,10 @@ ( ComponentDescription (..), MigrationDescription (..), PlanDescription (..),+ componentNameText, migrationIdComponent, migrationIdName,+ migrationNameText, planDescription, ) import Hasql.Connection.Settings qualified as Settings@@ -76,22 +80,19 @@ runMigrationCommand environment commandValue = case commandValue of Plan PlanOptions {inspection} ->- pure- ( success- "plan"- (PlanPayload (filterPlan inspection (planDescription (migrationPlan environment))))- )+ case validateInspection (migrationPlan environment) inspection of+ Left inputError -> pure (failure "plan" ExitUsageFailed (CliInputError inputError))+ Right description ->+ pure (success "plan" (PlanPayload (filterPlan inspection description))) List ListOptions {inspection} ->- pure- ( success- "list"- ( ListPayload- ( filterMigrations- inspection- (flattenPlanDescription (planDescription (migrationPlan environment)))- )+ case validateInspection (migrationPlan environment) inspection of+ Left inputError -> pure (failure "list" ExitUsageFailed (CliInputError inputError))+ Right description ->+ pure+ ( success+ "list"+ (ListPayload (filterMigrations inspection (flattenPlanDescription description))) )- ) Check CheckOptions {manifestPath} -> runCheck manifestPath Status StatusOptions {inspection, connection} -> runStatus environment inspection connection Verify VerifyOptions {inspection, connection} -> runVerify environment inspection connection@@ -105,7 +106,7 @@ runCheck manifestPath = do checked <- checkMigrationManifest manifestPath pure $ case checked of- Left manifestError -> failure "check" ExitUsageFailed (CliManifestError manifestError)+ Left manifestError -> failure "check" (manifestExitClass manifestError) (CliManifestError manifestError) Right entries -> success "check"@@ -114,31 +115,37 @@ ) runStatus :: CliEnvironment -> InspectionOptions -> ConnectionOptions -> IO CliOutcome-runStatus environment inspection connection = do- result <-- migrationStatusWith- (runnerOptions environment)- (selectProvider environment connection)- (migrationPlan environment)- pure $ case result of- Left migrationError -> failure "status" ExitExecutionFailed (CliMigrationError migrationError)- Right report -> success "status" (StatusPayload (filterStatus inspection report))+runStatus environment inspection connection =+ case validateInspection (migrationPlan environment) inspection of+ Left inputError -> pure (failure "status" ExitUsageFailed (CliInputError inputError))+ Right description -> do+ result <-+ migrationStatusWith+ (runnerOptions environment)+ (selectProvider environment connection)+ (migrationPlan environment)+ pure $ case result of+ Left migrationError -> failure "status" ExitExecutionFailed (CliMigrationError migrationError)+ Right report -> success "status" (StatusPayload (filterStatus description inspection report)) runVerify :: CliEnvironment -> InspectionOptions -> ConnectionOptions -> IO CliOutcome-runVerify environment inspection connection = do- result <-- verifyMigrationPlanWith- (runnerOptions environment)- (selectProvider environment connection)- (migrationPlan environment)- pure $ case result of- Left migrationError -> failure "verify" ExitExecutionFailed (CliMigrationError migrationError)- Right report ->- CliOutcome- { command = "verify",- exitClass = verificationExitClass report,- payload = Right (VerifyPayload (filterVerification inspection report))- }+runVerify environment inspection connection =+ case validateInspection (migrationPlan environment) inspection of+ Left inputError -> pure (failure "verify" ExitUsageFailed (CliInputError inputError))+ Right description -> do+ result <-+ verifyMigrationPlanWith+ (runnerOptions environment)+ (selectProvider environment connection)+ (migrationPlan environment)+ pure $ case result of+ Left migrationError -> failure "verify" ExitExecutionFailed (CliMigrationError migrationError)+ Right report ->+ CliOutcome+ { command = "verify",+ exitClass = verificationExitClass report,+ payload = Right (VerifyPayload (filterVerification description inspection report))+ } runUp :: CliEnvironment -> ConnectionOptions -> ExecutionOptions -> IO CliOutcome runUp environment connection execution = do@@ -177,17 +184,20 @@ runNew :: FilePath -> Text -> Maybe FilePath -> IO CliOutcome runNew manifestPath description requestedName =- case newMigrationOptions manifestPath requestedName initialSql of- Left authoringError ->- pure (failure "new" ExitUsageFailed (CliAuthoringError authoringError))- Right options -> do- result <- newMigration options- pure $ case result of+ case validateDescription description of+ Left inputError -> pure (failure "new" ExitUsageFailed (CliInputError inputError))+ Right validDescription ->+ case newMigrationOptions manifestPath requestedName (initialSql validDescription) of Left authoringError ->- failure "new" (authoringExitClass authoringError) (CliAuthoringError authoringError)- Right path -> success "new" (NewPayload path)+ pure (failure "new" ExitUsageFailed (CliAuthoringError authoringError))+ Right options -> do+ result <- newMigration options+ pure $ case result of+ Left authoringError ->+ failure "new" (authoringExitClass authoringError) (CliAuthoringError authoringError)+ Right path -> success "new" (NewPayload path) where- initialSql = Text.Encoding.encodeUtf8 ("-- " <> description <> "\n\n")+ initialSql validDescription = Text.Encoding.encodeUtf8 ("-- " <> validDescription <> "\n\n") selectProvider :: CliEnvironment -> ConnectionOptions -> ConnectionProvider selectProvider CliEnvironment {defaultConnection} ConnectionOptions {databaseSettings} =@@ -200,22 +210,30 @@ applyExecution :: ExecutionOptions -> RunOptions -> RunOptions applyExecution ExecutionOptions {lockWait, statementTimeout} =- withStatementTimeout statementTimeout . withLockWait lockWait+ maybe id withStatementTimeout statementTimeout+ . maybe id withLockWait lockWait verificationExitClass :: VerificationReport -> ExitClass verificationExitClass VerificationReport {issues}- | null issues = ExitSuccess+ | null issues = ExitSucceeded | otherwise = ExitVerificationFailed authoringExitClass :: AuthoringError -> ExitClass authoringExitClass authoringError = case authoringError of+ AuthoringManifestError ManifestIoError {} -> ExitExecutionFailed AuthoringIoError {} -> ExitExecutionFailed AuthoringCleanupError {} -> ExitExecutionFailed _ -> ExitUsageFailed +manifestExitClass :: ManifestError -> ExitClass+manifestExitClass manifestError =+ case manifestError of+ ManifestIoError {} -> ExitExecutionFailed+ _ -> ExitUsageFailed+ success :: Text -> CliPayload -> CliOutcome-success command payload = CliOutcome {command, exitClass = ExitSuccess, payload = Right payload}+success command payload = CliOutcome {command, exitClass = ExitSucceeded, payload = Right payload} failure :: Text -> ExitClass -> CliError -> CliOutcome failure command exitClass cliError = CliOutcome {command, exitClass, payload = Left cliError}@@ -239,18 +257,18 @@ filterMigrations :: InspectionOptions -> [MigrationDescription] -> [MigrationDescription] filterMigrations inspection = filter (matchesMigrationDescription inspection) -filterStatus :: InspectionOptions -> StatusReport -> StatusReport-filterStatus inspection (StatusReport issues applied pending unknown) =+filterStatus :: PlanDescription -> InspectionOptions -> StatusReport -> StatusReport+filterStatus description inspection (StatusReport issues applied pending unknown) = StatusReport- issues+ (filter (matchesVerificationIssue description inspection) issues) (filter (matchesMigrationId inspection) applied) (filter (matchesMigrationId inspection) pending) (filter (matchesMigrationId inspection . storedMigrationId) unknown) -filterVerification :: InspectionOptions -> VerificationReport -> VerificationReport-filterVerification inspection (VerificationReport issues applied pending unknown) =+filterVerification :: PlanDescription -> InspectionOptions -> VerificationReport -> VerificationReport+filterVerification description inspection (VerificationReport issues applied pending unknown) = VerificationReport- issues+ (filter (matchesVerificationIssue description inspection) issues) (filter (matchesMigrationId inspection) applied) (filter (matchesMigrationId inspection) pending) (filter (matchesMigrationId inspection . storedMigrationId) unknown)@@ -266,3 +284,47 @@ matchesComponent :: InspectionOptions -> ComponentName -> Bool matchesComponent InspectionOptions {component} candidate = maybe True (== candidate) component++validateInspection :: MigrationPlan -> InspectionOptions -> Either Text PlanDescription+validateInspection plan inspection@InspectionOptions {component, migration} = do+ let description = planDescription plan+ migrations = flattenPlanDescription description+ case component of+ Just requestedComponent+ | not (any (matchesComponent inspection . migrationIdComponent . migrationId) migrations) ->+ Left ("unknown component filter: " <> componentNameText requestedComponent)+ _ -> Right ()+ case migration of+ Just requestedMigration+ | not (any (matchesMigrationDescription inspection) migrations) ->+ Left ("unknown migration filter: " <> migrationNameText requestedMigration)+ _ -> Right ()+ Right description++matchesVerificationIssue :: PlanDescription -> InspectionOptions -> VerificationIssue -> Bool+matchesVerificationIssue description inspection issue =+ case issue of+ DuplicateStoredMigration identifier -> matchesMigrationId inspection identifier+ DuplicateStoredPosition issueComponent issuePosition ->+ matchesDuplicateStoredPosition description inspection issueComponent issuePosition+ StoredMigrationRunning identifier -> matchesMigrationId inspection identifier+ StoredMigrationFailed identifier -> matchesMigrationId inspection identifier+ MigrationPositionMismatch identifier _ _ -> matchesMigrationId inspection identifier+ MigrationChecksumMismatch identifier _ _ -> matchesMigrationId inspection identifier+ MigrationKindMismatch identifier _ _ -> matchesMigrationId inspection identifier+ MigrationTransactionModeMismatch identifier _ _ -> matchesMigrationId inspection identifier+ AppliedMigrationAfterGap identifier missing ->+ matchesMigrationId inspection identifier || matchesMigrationId inspection missing+ UnknownStoredMigration identifier -> matchesMigrationId inspection identifier+ PendingMigration identifier -> matchesMigrationId inspection identifier++matchesDuplicateStoredPosition :: PlanDescription -> InspectionOptions -> ComponentName -> Int -> Bool+matchesDuplicateStoredPosition description inspection@InspectionOptions {migration} issueComponent issuePosition+ | not (matchesComponent inspection issueComponent) = False+ | otherwise =+ case migration of+ Nothing -> True+ Just _ ->+ any+ (\MigrationDescription {migrationId, position} -> position == issuePosition && matchesMigrationId inspection migrationId)+ (flattenPlanDescription description)
src/Database/PostgreSQL/Migrate/CLI/Json.hs view
@@ -7,15 +7,14 @@ import Data.Aeson (Value, object, (.=)) import Data.Aeson.Types (Pair)-import Data.ByteString qualified as ByteString import Data.List.NonEmpty qualified as NonEmpty import Data.Set qualified as Set import Data.Text qualified as Text import Data.Time (UTCTime, defaultTimeLocale, formatTime) import Database.PostgreSQL.Migrate import Database.PostgreSQL.Migrate.CLI.Outcome+import Database.PostgreSQL.Migrate.CLI.Types (checksumText) import Database.PostgreSQL.Migrate.Internal-import Numeric qualified import PgMigrate.CLI.Prelude -- | Supported machine-readable CLI schema version.@@ -28,7 +27,7 @@ object ( [ "schemaVersion" .= jsonSchemaVersion, "command" .= command,- "ok" .= (exitClass == ExitSuccess)+ "ok" .= (exitClass == ExitSucceeded) ] <> case payload of Left cliError -> ["error" .= errorValue cliError]@@ -37,7 +36,7 @@ -- | Render an adapter import report using JSON schema v1. renderHistoryImportJson :: Text -> HistoryImportReport -> Value-renderHistoryImportJson sourceName HistoryImportReport {importResults} =+renderHistoryImportJson sourceName HistoryImportReport {importResults, cleanupIssues} = object [ "schemaVersion" .= jsonSchemaVersion, "command" .= ("import" :: Text),@@ -45,7 +44,8 @@ "data" .= object [ "source" .= sourceName,- "results" .= (historyImportResultValue <$> NonEmpty.toList importResults)+ "results" .= (historyImportResultValue <$> NonEmpty.toList importResults),+ "cleanup_issues" .= (cleanupIssueValue <$> cleanupIssues) ] ] @@ -197,11 +197,12 @@ object ["type" .= issueType, "id" .= migrationIdText identifier, expected, actual] migrationReportValue :: MigrationReport -> Value-migrationReportValue (MigrationReport startedAt finishedAt results) =+migrationReportValue MigrationReport {startedAt, finishedAt, results, cleanupIssues} = object [ "startedAt" .= utcText startedAt, "finishedAt" .= utcText finishedAt,- "results" .= (migrationResultValue <$> NonEmpty.toList results)+ "results" .= (migrationResultValue <$> NonEmpty.toList results),+ "cleanup_issues" .= (cleanupIssueValue <$> cleanupIssues) ] migrationResultValue :: MigrationResult -> Value@@ -213,14 +214,30 @@ ] repairReportValue :: RepairReport -> Value-repairReportValue (RepairReport identifier operation oldStatus newStatus) =+repairReportValue RepairReport {repairedMigration, operation, oldStatus, newStatus, cleanupIssues} = object- [ "id" .= migrationIdText identifier,+ [ "id" .= migrationIdText repairedMigration, "operation" .= repairOperationText operation, "oldStatus" .= statusText oldStatus,- "newStatus" .= statusText newStatus+ "newStatus" .= statusText newStatus,+ "cleanup_issues" .= (cleanupIssueValue <$> cleanupIssues) ] +cleanupIssueValue :: CleanupIssue -> Value+cleanupIssueValue cleanupIssue =+ case cleanupIssue of+ AdvisoryUnlockReturnedFalse -> object ["type" .= ("advisoryUnlockReturnedFalse" :: Text)]+ AdvisoryUnlockFailed sessionError ->+ object+ [ "type" .= ("advisoryUnlockFailed" :: Text),+ "message" .= Text.pack (show sessionError)+ ]+ StatementTimeoutRestoreFailed sessionError ->+ object+ [ "type" .= ("statementTimeoutRestoreFailed" :: Text),+ "message" .= Text.pack (show sessionError)+ ]+ errorValue :: CliError -> Value errorValue cliError = object@@ -231,6 +248,7 @@ cliErrorType :: CliError -> Text cliErrorType cliError = case cliError of+ CliInputError _ -> "input.invalid" CliMigrationError migrationError -> "migration." <> migrationErrorType migrationError CliRepairDefinitionError _ -> "repair.definition" CliRepairError _ -> "repair.execution"@@ -240,6 +258,7 @@ renderCliErrorMessage :: CliError -> String renderCliErrorMessage cliError = case cliError of+ CliInputError err -> Text.unpack err CliMigrationError err -> show err CliRepairDefinitionError err -> show err CliRepairError err -> show err@@ -273,15 +292,6 @@ componentNameText (migrationIdComponent identifier) <> "/" <> migrationNameText (migrationIdName identifier)--checksumText :: MigrationChecksum -> Text-checksumText =- Text.pack . concatMap renderByte . ByteString.unpack . migrationChecksumBytes- where- renderByte byte =- case Numeric.showHex byte "" of- [digit] -> ['0', digit]- digits -> digits kindText :: MigrationKind -> Text kindText kind = case kind of SqlKind -> "sql"; HaskellKind -> "haskell"
src/Database/PostgreSQL/Migrate/CLI/Outcome.hs view
@@ -26,7 +26,7 @@ -- | Stable process-exit classification chosen by the application. data ExitClass- = ExitSuccess+ = ExitSucceeded | ExitVerificationFailed | ExitUsageFailed | ExitExecutionFailed@@ -53,7 +53,8 @@ -- | Structured CLI definition, runner, repair, authoring, or import failure. data CliError- = CliMigrationError !MigrationError+ = CliInputError !Text+ | CliMigrationError !MigrationError | CliRepairDefinitionError !RepairDefinitionError | CliRepairError !RepairError | CliManifestError !ManifestError
src/Database/PostgreSQL/Migrate/CLI/Parser.hs view
@@ -23,7 +23,8 @@ import PgMigrate.CLI.Prelude import Text.Read qualified as Read --- | Build the reusable parser, using the plan for target-aware choices.+-- | Build the reusable parser. The plan parameter is reserved for future+-- target-aware completion. migrationCommandParser :: MigrationPlan -> Parser MigrationCommand migrationCommandParser _ = subparser (commandGroup "Inspection" <> inspectionCommands)@@ -79,7 +80,7 @@ checkOptionsParser :: Parser CheckOptions checkOptionsParser = CheckOptions- <$> strArgument (metavar "MANIFEST" <> help "Path to the ordered migration manifest")+ <$> strOption (long "manifest" <> metavar "PATH" <> help "Ordered migration manifest to validate") <*> outputOptionsParser statusOptionsParser :: Parser StatusOptions@@ -118,7 +119,7 @@ newOptionsParser = NewOptions <$> strOption (long "manifest" <> metavar "PATH" <> help "Ordered manifest to append")- <*> strOption (long "description" <> metavar "TEXT" <> help "Human description written into the new SQL file")+ <*> option descriptionReader (long "description" <> metavar "TEXT" <> help "Single-line description written into the new SQL file") <*> optional (strOption (long "name" <> metavar "BASENAME" <> help "Explicit SQL basename when numeric inference is unavailable")) <*> outputOptionsParser @@ -143,14 +144,7 @@ "Execution" ( ExecutionOptions <$> lockWaitParser- <*> optional- ( option- positiveMillisecondsReader- ( long "statement-timeout"- <> metavar "MILLISECONDS"- <> help "Positive PostgreSQL statement timeout in milliseconds"- )- )+ <*> statementTimeoutParser ) outputOptionsParser :: Parser OutputOptions@@ -172,19 +166,35 @@ (option migrationNameReader (long "migration" <> metavar "MIGRATION" <> help "Limit inspection output to one migration name")) ) -lockWaitParser :: Parser LockWait+lockWaitParser :: Parser (Maybe LockWait) lockWaitParser =- flag' NoWait (long "no-wait" <> help "Fail immediately when the advisory lock is unavailable")- <|> ( WaitFor- <$> option- positiveMillisecondsReader- ( long "lock-timeout"- <> metavar "MILLISECONDS"- <> help "Wait at most this many positive milliseconds for the advisory lock"- )- )- <|> pure WaitIndefinitely+ optional+ ( flag' NoWait (long "no-wait" <> help "Fail immediately when the advisory lock is unavailable")+ <|> ( WaitFor+ <$> option+ positiveMillisecondsReader+ ( long "lock-timeout"+ <> metavar "MILLISECONDS"+ <> help "Wait at most this many positive milliseconds for the advisory lock"+ )+ )+ <|> flag' WaitIndefinitely (long "wait" <> help "Wait indefinitely for the advisory lock")+ ) +statementTimeoutParser :: Parser (Maybe (Maybe NominalDiffTime))+statementTimeoutParser =+ optional+ ( ( Just+ <$> option+ positiveMillisecondsReader+ ( long "statement-timeout"+ <> metavar "MILLISECONDS"+ <> help "Set a positive PostgreSQL statement timeout in milliseconds"+ )+ )+ <|> flag' Nothing (long "no-statement-timeout" <> help "Run without a temporary statement timeout")+ )+ repairOperationParser :: Parser RepairOperation repairOperationParser = flag' MarkApplied (long "mark-applied" <> help "Record the inspected nontransactional result as applied")@@ -220,3 +230,9 @@ Left err -> Left (show err) Right validated -> Right validated _ -> Left "expected COMPONENT/MIGRATION"++descriptionReader :: ReadM Text+descriptionReader = eitherReader $ \input ->+ case validateDescription (Text.pack input) of+ Left err -> Left (Text.unpack err)+ Right description -> Right description
src/Database/PostgreSQL/Migrate/CLI/Text.hs view
@@ -3,14 +3,13 @@ ) where -import Data.ByteString qualified as ByteString import Data.List.NonEmpty qualified as NonEmpty import Data.Set qualified as Set import Data.Text qualified as Text import Database.PostgreSQL.Migrate import Database.PostgreSQL.Migrate.CLI.Outcome+import Database.PostgreSQL.Migrate.CLI.Types (checksumText) import Database.PostgreSQL.Migrate.Internal-import Numeric qualified import PgMigrate.CLI.Prelude -- | Render a stable human-oriented outcome.@@ -22,6 +21,7 @@ renderCliError :: CliError -> Text renderCliError cliError = case cliError of+ CliInputError err -> err CliMigrationError err -> Text.pack (show err) CliRepairDefinitionError err -> Text.pack (show err) CliRepairError err -> Text.pack (show err)@@ -94,12 +94,13 @@ ) renderMigrationReport :: MigrationReport -> Text-renderMigrationReport (MigrationReport startedAt finishedAt results) =+renderMigrationReport MigrationReport {startedAt, finishedAt, results, cleanupIssues} = Text.unlines ( [ "started=" <> Text.pack (show startedAt), "finished=" <> Text.pack (show finishedAt) ] <> (renderMigrationResult <$> NonEmpty.toList results)+ <> (renderCleanupIssue <$> cleanupIssues) ) renderMigrationResult :: MigrationResult -> Text@@ -112,16 +113,21 @@ ] renderRepairReport :: RepairReport -> Text-renderRepairReport (RepairReport identifier operation oldStatus newStatus) =- Text.intercalate- " "- [ renderMigrationId identifier,- "operation=" <> renderRepairOperation operation,- "old_status=" <> renderStatusName oldStatus,- "new_status=" <> renderStatusName newStatus- ]- <> "\n"+renderRepairReport RepairReport {repairedMigration, operation, oldStatus, newStatus, cleanupIssues} =+ Text.unlines+ ( Text.intercalate+ " "+ [ renderMigrationId repairedMigration,+ "operation=" <> renderRepairOperation operation,+ "old_status=" <> renderStatusName oldStatus,+ "new_status=" <> renderStatusName newStatus+ ]+ : (renderCleanupIssue <$> cleanupIssues)+ ) +renderCleanupIssue :: CleanupIssue -> Text+renderCleanupIssue cleanupIssue = "cleanup_issue=" <> Text.pack (show cleanupIssue)+ renderStoredMigrationId :: StoredMigration -> Text renderStoredMigrationId StoredMigration {storedMigrationId} = renderMigrationId storedMigrationId @@ -130,15 +136,6 @@ componentNameText (migrationIdComponent identifier) <> "/" <> migrationNameText (migrationIdName identifier)--checksumText :: MigrationChecksum -> Text-checksumText =- Text.pack . concatMap renderByte . ByteString.unpack . migrationChecksumBytes- where- renderByte byte =- case Numeric.showHex byte "" of- [digit] -> ['0', digit]- digits -> digits renderKind :: MigrationKind -> Text renderKind kind = case kind of SqlKind -> "sql"; HaskellKind -> "haskell"
src/Database/PostgreSQL/Migrate/CLI/Types.hs view
@@ -13,18 +13,26 @@ RepairOptions (..), NewOptions (..), MigrationCommand (..),+ checksumText,+ validateDescription, ) where +import Data.ByteString qualified as ByteString+import Data.Char qualified as Char+import Data.Text qualified as Text import Database.PostgreSQL.Migrate ( ComponentName, Confirmation, LockWait,+ MigrationChecksum, MigrationId, MigrationName, RepairOperation, )+import Database.PostgreSQL.Migrate.Internal (migrationChecksumBytes) import Hasql.Connection.Settings qualified as Settings+import Numeric qualified import PgMigrate.CLI.Prelude -- | Human-readable or versioned JSON rendering.@@ -41,8 +49,8 @@ -- | Shared lock and statement-timeout flags. data ExecutionOptions = ExecutionOptions- { lockWait :: !LockWait,- statementTimeout :: !(Maybe NominalDiffTime)+ { lockWait :: !(Maybe LockWait),+ statementTimeout :: !(Maybe (Maybe NominalDiffTime)) } deriving stock (Generic, Eq, Show) @@ -136,3 +144,23 @@ | Repair !RepairOptions | New !NewOptions deriving stock (Generic, Eq, Show)++checksumText :: MigrationChecksum -> Text+checksumText =+ Text.pack . concatMap renderByte . ByteString.unpack . migrationChecksumBytes+ where+ renderByte byte =+ case Numeric.showHex byte "" of+ [digit] -> ['0', digit]+ digits -> digits++validateDescription :: Text -> Either Text Text+validateDescription description =+ case Text.find Char.isControl description of+ Nothing -> Right description+ Just character ->+ Left+ ( "invalid --description: control character "+ <> Text.pack (show character)+ <> " is not allowed"+ )
test/golden/help/check.txt view
@@ -1,9 +1,9 @@-Usage: pg-migrate-cli-help-fixture check MANIFEST [--json]+Usage: pg-migrate-cli-help-fixture check --manifest PATH [--json] Validate an ordered SQL manifest without a database Available options:- MANIFEST Path to the ordered migration manifest+ --manifest PATH Ordered migration manifest to validate -h,--help Show this help text Output
test/golden/help/new.txt view
@@ -5,7 +5,7 @@ Available options: --manifest PATH Ordered manifest to append- --description TEXT Human description written into the new SQL file+ --description TEXT Single-line description written into the new SQL file --name BASENAME Explicit SQL basename when numeric inference is unavailable -h,--help Show this help text
test/golden/help/repair.txt view
@@ -1,7 +1,7 @@ Usage: pg-migrate-cli-help-fixture repair COMPONENT/MIGRATION (--mark-applied | --retry) --reason TEXT --confirm- [--database-url URL] [--no-wait | --lock-timeout MILLISECONDS]- [--statement-timeout MILLISECONDS] [--json]+ [--database-url URL] [--no-wait | --lock-timeout MILLISECONDS | --wait]+ [--statement-timeout MILLISECONDS | --no-statement-timeout] [--json] Repair one nontransactional migration after operator inspection @@ -24,8 +24,11 @@ --lock-timeout MILLISECONDS Wait at most this many positive milliseconds for the advisory lock+ --wait Wait indefinitely for the advisory lock --statement-timeout MILLISECONDS- Positive PostgreSQL statement timeout in milliseconds+ Set a positive PostgreSQL statement timeout in+ milliseconds+ --no-statement-timeout Run without a temporary statement timeout Output --json Emit JSON schema version 1
test/golden/help/up.txt view
@@ -1,6 +1,6 @@ Usage: pg-migrate-cli-help-fixture up- [--database-url URL] [--no-wait | --lock-timeout MILLISECONDS]- [--statement-timeout MILLISECONDS] [--json]+ [--database-url URL] [--no-wait | --lock-timeout MILLISECONDS | --wait]+ [--statement-timeout MILLISECONDS | --no-statement-timeout] [--json] Apply the complete migration plan; selective execution is unavailable in v1 @@ -14,8 +14,11 @@ --lock-timeout MILLISECONDS Wait at most this many positive milliseconds for the advisory lock+ --wait Wait indefinitely for the advisory lock --statement-timeout MILLISECONDS- Positive PostgreSQL statement timeout in milliseconds+ Set a positive PostgreSQL statement timeout in+ milliseconds+ --no-statement-timeout Run without a temporary statement timeout Output --json Emit JSON schema version 1
test/golden/json/import.json view
@@ -13,6 +13,7 @@ "id": "accounts/0002", "outcome": "alreadyImported" }- ]+ ],+ "cleanup_issues": [] } }
test/golden/json/repair.json view
@@ -6,6 +6,7 @@ "id": "accounts/0001", "operation": "markApplied", "oldStatus": "failed",- "newStatus": "applied"+ "newStatus": "applied",+ "cleanup_issues": [] } }
+ test/golden/json/up-cleanup.json view
@@ -0,0 +1,21 @@+{+ "schemaVersion": 1,+ "command": "up",+ "ok": true,+ "data": {+ "startedAt": "2026-07-10T12:00:00Z",+ "finishedAt": "2026-07-10T12:00:01Z",+ "results": [+ {+ "id": "accounts/0001",+ "outcome": "appliedNow",+ "durationMilliseconds": 125+ }+ ],+ "cleanup_issues": [+ {+ "type": "advisoryUnlockReturnedFalse"+ }+ ]+ }+}
test/golden/json/up.json view
@@ -11,6 +11,7 @@ "outcome": "appliedNow", "durationMilliseconds": 125 }- ]+ ],+ "cleanup_issues": [] } }
test/integration/Main.hs view
@@ -1,6 +1,8 @@ module Main (main) where import Control.Exception qualified as Exception+import Data.ByteString (ByteString)+import Data.IORef qualified as IORef import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty (..)) import Data.Set qualified as Set@@ -29,16 +31,22 @@ testGroup "pg-migrate CLI PostgreSQL" [ testCase "status verify and full up preserve their contracts" (testCommandLifecycle settings),- testCase "confirmed repair dispatches through the shared lifecycle" (testRepairLifecycle settings)+ testCase "confirmed repair dispatches through the shared lifecycle" (testRepairLifecycle settings),+ testCase "filtered verification keeps full-report exit classification" (testFilteredVerification settings) ] testCommandLifecycle :: Settings.Settings -> Assertion testCommandLifecycle settings = withCleanLedger settings "pgmigrate_cli_commands" 0x70676D636C690001 $ \options -> do- let environment = cliEnvironment settings commandPlan options+ observedEvents <- IORef.newIORef []+ let configuredOptions =+ withEventHandler (IORef.modifyIORef' observedEvents . (:)) $+ withStatementTimeout (Just 5) $+ withLockWait NoWait options+ environment = cliEnvironment settings commandPlan configuredOptions statusBefore <- runMigrationCommand environment statusCommand- exitClass statusBefore @?= ExitSuccess+ exitClass statusBefore @?= ExitSucceeded case payload statusBefore of Right (StatusPayload (StatusReport issues applied pending unknown)) -> do issues @?= []@@ -56,11 +64,13 @@ other -> assertFailure ("unexpected pre-run verify outcome: " <> show other) firstUp <- runMigrationCommand environment upCommand- exitClass firstUp @?= ExitSuccess+ exitClass firstUp @?= ExitSucceeded assertSingleOutcome AppliedNow firstUp+ events <- IORef.readIORef observedEvents+ assertBool "application lock-wait setting survives absent CLI flags" (LockWaitStarted NoWait `elem` events) verifyAfter <- runMigrationCommand environment verifyCommand- exitClass verifyAfter @?= ExitSuccess+ exitClass verifyAfter @?= ExitSucceeded case payload verifyAfter of Right (VerifyPayload (VerificationReport issues applied pending unknown)) -> do issues @?= []@@ -70,7 +80,7 @@ other -> assertFailure ("unexpected post-run verify outcome: " <> show other) secondUp <- runMigrationCommand environment upCommand- exitClass secondUp @?= ExitSuccess+ exitClass secondUp @?= ExitSucceeded assertSingleOutcome AlreadyApplied secondUp testRepairLifecycle :: Settings.Settings -> Assertion@@ -95,19 +105,40 @@ jsonOutput ) )- exitClass repaired @?= ExitSuccess+ exitClass repaired @?= ExitSucceeded case payload repaired of- Right (RepairPayload (RepairReport identifier MarkApplied Failed Applied)) ->+ Right (RepairPayload (RepairReport identifier MarkApplied Failed Applied [])) -> identifier @?= failingMigrationId other -> assertFailure ("unexpected repair outcome: " <> show other) verified <- runMigrationCommand environment verifyCommand- exitClass verified @?= ExitSuccess+ exitClass verified @?= ExitSucceeded +testFilteredVerification :: Settings.Settings -> Assertion+testFilteredVerification settings =+ withCleanLedger settings "pgmigrate_cli_filters" 0x70676D636C690003 $ \options -> do+ let originalEnvironment = cliEnvironment settings (filteredPlan "SELECT 2") options+ changedEnvironment = cliEnvironment settings (filteredPlan "SELECT 3") options+ selectedInspection = InspectionOptions (Just filterAccounts) Nothing+ selectedVerification = Verify (VerifyOptions selectedInspection noConnectionOverride jsonOutput)++ applied <- runMigrationCommand originalEnvironment upCommand+ exitClass applied @?= ExitSucceeded++ filtered <- runMigrationCommand changedEnvironment selectedVerification+ exitClass filtered @?= ExitVerificationFailed+ case payload filtered of+ Right (VerifyPayload (VerificationReport issues appliedMigrations pending unknown)) -> do+ issues @?= []+ appliedMigrations @?= [filterAccountsMigration]+ pending @?= []+ unknown @?= []+ other -> assertFailure ("unexpected filtered verification outcome: " <> show other)+ assertSingleOutcome :: MigrationOutcome -> CliOutcome -> Assertion assertSingleOutcome expected outcome = case payload outcome of- Right (UpPayload (MigrationReport _ _ (MigrationResult _ actual _ :| []))) ->+ Right (UpPayload (MigrationReport _ _ (MigrationResult _ actual _ :| []) [])) -> actual @?= expected other -> assertFailure ("unexpected up outcome: " <> show other) @@ -127,7 +158,7 @@ noConnectionOverride = ConnectionOptions Nothing defaultExecution :: ExecutionOptions-defaultExecution = ExecutionOptions WaitIndefinitely Nothing+defaultExecution = ExecutionOptions Nothing Nothing jsonOutput :: OutputOptions jsonOutput = OutputOptions JsonOutput@@ -143,7 +174,16 @@ ( expectRight ( sqlMigration "0001"- "CREATE TABLE pgmigrate_cli_commands.cli_command_probe (id bigint PRIMARY KEY)"+ """+ DO $$+ BEGIN+ IF current_setting('statement_timeout') <> '5s' THEN+ RAISE EXCEPTION 'application statement timeout was overridden';+ END IF;+ END+ $$;+ CREATE TABLE pgmigrate_cli_commands.cli_command_probe (id bigint PRIMARY KEY)+ """ ) :| [] )@@ -151,6 +191,32 @@ :| [] ) )++filteredPlan :: ByteString -> MigrationPlan+filteredPlan billingSql =+ expectRight+ ( migrationPlan+ ( expectRight+ ( migrationComponent+ "filter-accounts"+ Set.empty+ (expectRight (sqlMigration "0001" "SELECT 1") :| [])+ )+ :| [ expectRight+ ( migrationComponent+ "filter-billing"+ (Set.singleton "filter-accounts")+ (expectRight (sqlMigration "0001" billingSql) :| [])+ )+ ]+ )+ )++filterAccounts :: ComponentName+filterAccounts = expectRight (componentName "filter-accounts")++filterAccountsMigration :: MigrationId+filterAccountsMigration = expectRight (migrationId "filter-accounts" "0001") failingPlan :: MigrationPlan failingPlan =
test/unit/Test/Handler.hs view
@@ -7,7 +7,10 @@ import Data.Text qualified as Text import Database.PostgreSQL.Migrate import Database.PostgreSQL.Migrate.CLI-import Database.PostgreSQL.Migrate.Embed (AuthoringError (ExplicitMigrationNameRequired))+import Database.PostgreSQL.Migrate.Embed+ ( AuthoringError (..),+ ManifestError (..),+ ) import Hasql.Connection.Settings qualified as Settings import System.Directory qualified as Directory import System.FilePath ((</>))@@ -20,10 +23,14 @@ "handler" [ testCase "plan text is stable and preserves component order" testPlanText, testCase "list filters narrow output without changing plan order" testListFilter,+ testCase "unknown inspection filters fail before IO" testUnknownInspectionFilters, testCase "check returns exact-byte checksums" testCheck, testCase "new creates and appends a migration without applying it" testNew,+ testCase "new rejects control characters before writing files" testNewRejectsControlCharacters, testCase "new reports when a nonnumeric manifest requires an explicit name" testNewRequiresName,- testCase "manifest failures are typed usage outcomes" testCheckFailure+ testCase "manifest IO failures are typed execution outcomes" testCheckIoFailure,+ testCase "manifest validation failures remain typed usage outcomes" testCheckValidationFailure,+ testCase "new classifies manifest IO failures as execution outcomes" testNewManifestIoFailure ] testPlanText :: Assertion@@ -32,7 +39,7 @@ runMigrationCommand fixtureEnvironment (Plan (PlanOptions noInspection textOutput))- exitClass outcome @?= ExitSuccess+ exitClass outcome @?= ExitSucceeded renderMigrationCommandText outcome @?= Text.unlines [ "1. accounts depends=[] migrations=1",@@ -46,7 +53,7 @@ runMigrationCommand fixtureEnvironment (List (ListOptions (InspectionOptions (Just billing) Nothing) textOutput))- exitClass outcome @?= ExitSuccess+ exitClass outcome @?= ExitSucceeded let rendered = renderMigrationCommandText outcome assertBool "billing migration is present" ("billing/0001" `Text.isInfixOf` rendered) assertBool "accounts migration is filtered out" (not ("accounts/0001" `Text.isInfixOf` rendered))@@ -54,6 +61,26 @@ where validOutputCharacter character = not (character >= 'A' && character <= 'F') +testUnknownInspectionFilters :: Assertion+testUnknownInspectionFilters = do+ let unknownComponent = expectRight (componentName "missing")+ unknownMigration = expectRight (migrationName "missing")+ accounts = expectRight (componentName "accounts")+ commands =+ [ Plan (PlanOptions (InspectionOptions (Just unknownComponent) Nothing) textOutput),+ List (ListOptions (InspectionOptions (Just unknownComponent) Nothing) textOutput),+ Status (StatusOptions (InspectionOptions (Just unknownComponent) Nothing) (ConnectionOptions Nothing) textOutput),+ Verify (VerifyOptions (InspectionOptions (Just accounts) (Just unknownMigration)) (ConnectionOptions Nothing) textOutput)+ ]+ outcomes <- mapM (runMigrationCommand fixtureEnvironment) commands+ mapM_ assertUnknownFilter outcomes+ where+ assertUnknownFilter outcome = do+ exitClass outcome @?= ExitUsageFailed+ case payload outcome of+ Left (CliInputError message) -> assertBool "diagnostic names the unknown filter" ("unknown" `Text.isInfixOf` message)+ result -> assertFailure ("expected typed unknown-filter error, received: " <> show result)+ testCheck :: Assertion testCheck = withFixtureDirectory "check" $ \directory -> do@@ -64,7 +91,7 @@ runMigrationCommand fixtureEnvironment (Check (CheckOptions manifest textOutput))- exitClass outcome @?= ExitSuccess+ exitClass outcome @?= ExitSucceeded let rendered = renderMigrationCommandText outcome assertBool "manifest entry is rendered" ("0001.sql checksum=" `Text.isPrefixOf` rendered) Text.length (Text.takeWhileEnd (/= '=') (Text.strip rendered)) @?= 64@@ -79,22 +106,65 @@ runMigrationCommand fixtureEnvironment (New (NewOptions manifest "add profile" Nothing textOutput))- exitClass outcome @?= ExitSuccess+ exitClass outcome @?= ExitSucceeded renderMigrationCommandText outcome @?= "created " <> Text.pack (directory </> "0002.sql") <> "\n" ByteString.readFile (directory </> "0002.sql") >>= (@?= "-- add profile\n\n") ByteString.readFile manifest >>= (@?= "0001.sql\n0002.sql\n") -testCheckFailure :: Assertion-testCheckFailure =+testNewRejectsControlCharacters :: Assertion+testNewRejectsControlCharacters =+ withFixtureDirectory "new-description" $ \directory -> do+ let manifest = directory </> "manifest"+ ByteString.writeFile (directory </> "0001.sql") "SELECT 1;\n"+ ByteString.writeFile manifest "0001.sql\n"+ outcome <-+ runMigrationCommand+ fixtureEnvironment+ (New (NewOptions manifest "add profile\nDROP TABLE accounts" Nothing textOutput))+ exitClass outcome @?= ExitUsageFailed+ case payload outcome of+ Left (CliInputError message) -> assertBool "diagnostic names the newline" ("\\n" `Text.isInfixOf` message)+ result -> assertFailure ("expected typed description error, received: " <> show result)+ Directory.doesFileExist (directory </> "0002.sql") >>= (@?= False)+ ByteString.readFile manifest >>= (@?= "0001.sql\n")++testCheckIoFailure :: Assertion+testCheckIoFailure = withFixtureDirectory "missing" $ \directory -> do outcome <- runMigrationCommand fixtureEnvironment (Check (CheckOptions (directory </> "missing-manifest") textOutput))+ exitClass outcome @?= ExitExecutionFailed+ case payload outcome of+ Left (CliManifestError ManifestIoError {}) -> pure ()+ result -> assertFailure ("expected typed manifest IO error, received: " <> show result)++testCheckValidationFailure :: Assertion+testCheckValidationFailure =+ withFixtureDirectory "invalid-manifest" $ \directory -> do+ let manifest = directory </> "manifest"+ ByteString.writeFile manifest ""+ outcome <-+ runMigrationCommand+ fixtureEnvironment+ (Check (CheckOptions manifest textOutput)) exitClass outcome @?= ExitUsageFailed case payload outcome of- Left (CliManifestError _) -> pure ()- result -> assertFailure ("expected typed manifest error, received: " <> show result)+ Left (CliManifestError EmptyManifest) -> pure ()+ result -> assertFailure ("expected typed manifest validation error, received: " <> show result)++testNewManifestIoFailure :: Assertion+testNewManifestIoFailure =+ withFixtureDirectory "new-missing-manifest" $ \directory -> do+ outcome <-+ runMigrationCommand+ fixtureEnvironment+ (New (NewOptions (directory </> "missing-manifest") "add profile" Nothing textOutput))+ exitClass outcome @?= ExitExecutionFailed+ case payload outcome of+ Left (CliAuthoringError (AuthoringManifestError ManifestIoError {})) -> pure ()+ result -> assertFailure ("expected typed authoring manifest IO error, received: " <> show result) testNewRequiresName :: Assertion testNewRequiresName =
test/unit/Test/Json.hs view
@@ -28,6 +28,7 @@ goldenCase "status" statusOutcome, goldenCase "verify" verifyOutcome, goldenCase "up" upOutcome,+ goldenCase "up-cleanup" upCleanupOutcome, goldenCase "repair" repairOutcome, goldenCase "error" errorOutcome, testCase "import matches its golden contract" testImportGolden,@@ -70,6 +71,7 @@ statusOutcome, verifyOutcome, upOutcome,+ upCleanupOutcome, repairOutcome, errorOutcome ]@@ -112,14 +114,28 @@ fixtureStartedAt fixtureFinishedAt (MigrationResult migrationIdentifier AppliedNow (Just 0.125) :| [])+ [] ) ) +upCleanupOutcome :: CliOutcome+upCleanupOutcome =+ successful+ "up"+ ( UpPayload+ ( MigrationReport+ fixtureStartedAt+ fixtureFinishedAt+ (MigrationResult migrationIdentifier AppliedNow (Just 0.125) :| [])+ [AdvisoryUnlockReturnedFalse]+ )+ )+ repairOutcome :: CliOutcome repairOutcome = successful "repair"- (RepairPayload (RepairReport migrationIdentifier MarkApplied Failed Applied))+ (RepairPayload (RepairReport migrationIdentifier MarkApplied Failed Applied [])) errorOutcome :: CliOutcome errorOutcome =@@ -130,7 +146,7 @@ } successful :: Text -> CliPayload -> CliOutcome-successful command payload = CliOutcome {command, exitClass = ExitSuccess, payload = Right payload}+successful command payload = CliOutcome {command, exitClass = ExitSucceeded, payload = Right payload} accounts :: ComponentName accounts = expectRight (componentName "accounts")@@ -150,9 +166,11 @@ importReport :: HistoryImportReport importReport = HistoryImportReport- ( HistoryImportResult migrationIdentifier Imported- :| [HistoryImportResult (expectRight (migrationId "accounts" "0002")) AlreadyImported]- )+ { importResults =+ HistoryImportResult migrationIdentifier Imported+ :| [HistoryImportResult (expectRight (migrationId "accounts" "0002")) AlreadyImported],+ cleanupIssues = []+ } expectRight :: (Show error) => Either error value -> value expectRight = either (error . show) id
test/unit/Test/Parser.hs view
@@ -21,9 +21,14 @@ testCase "up has no selective execution filters" testUpRejectsFilters, testCase "durations must be positive integer milliseconds" testDurationValidation, testCase "lock wait flags conflict" testConflictingWaitFlags,+ testCase "absent execution flags preserve application options" testAbsentExecutionOverrides,+ testCase "lock wait flags produce explicit overrides" testLockWaitOverrides,+ testCase "statement timeout flags produce explicit overrides" testStatementTimeoutOverrides,+ testCase "description rejects control characters" testDescriptionValidation, testCase "repair requires an operation and confirmation" testRepairConfirmation, testCase "repair validates the component/migration target" testRepairTarget, testCase "parsing does not imply database settings" testNoImplicitDatabaseSettings,+ testCase "check uses the same manifest flag as new" testCheckManifestFlag, testCase "plain completion derives all commands from the parser" testPlainCompletion, testCase "enriched completion derives execution flags and descriptions" testEnrichedCompletion, testGroup "help goldens" (uncurry goldenHelpCase <$> helpGoldens)@@ -70,7 +75,30 @@ testConflictingWaitFlags = do failure <- parseFailure ["up", "--no-wait", "--lock-timeout", "100"] assertContains "Invalid option" failure+ statementFailure <- parseFailure ["up", "--statement-timeout", "100", "--no-statement-timeout"]+ assertContains "Invalid option" statementFailure +testAbsentExecutionOverrides :: Assertion+testAbsentExecutionOverrides =+ parsedExecution [] @?= ExecutionOptions Nothing Nothing++testLockWaitOverrides :: Assertion+testLockWaitOverrides = do+ parsedExecution ["--no-wait"] @?= ExecutionOptions (Just NoWait) Nothing+ parsedExecution ["--lock-timeout", "250"] @?= ExecutionOptions (Just (WaitFor 0.25)) Nothing+ parsedExecution ["--wait"] @?= ExecutionOptions (Just WaitIndefinitely) Nothing++testStatementTimeoutOverrides :: Assertion+testStatementTimeoutOverrides = do+ parsedExecution ["--statement-timeout", "250"] @?= ExecutionOptions Nothing (Just (Just 0.25))+ parsedExecution ["--no-statement-timeout"] @?= ExecutionOptions Nothing (Just Nothing)++testDescriptionValidation :: Assertion+testDescriptionValidation = do+ failure <- parseFailure ["new", "--manifest", "manifest", "--description", "add profile\nDROP TABLE accounts"]+ assertContains "--description" failure+ assertContains "\\n" failure+ testRepairConfirmation :: Assertion testRepairConfirmation = do missingOperation <- parseFailure ["repair", "accounts/0001", "--reason", "inspected", "--confirm"]@@ -89,6 +117,14 @@ Status StatusOptions {connection = ConnectionOptions {databaseSettings = Nothing}} -> pure () result -> assertFailure ("expected status without database settings, received: " <> show result) +testCheckManifestFlag :: Assertion+testCheckManifestFlag = do+ case parseSuccess ["check", "--manifest", "migrations/manifest"] of+ Check CheckOptions {manifestPath = "migrations/manifest"} -> pure ()+ parsedCommand -> assertFailure ("expected check command, received: " <> show parsedCommand)+ positionalFailure <- parseFailure ["check", "migrations/manifest"]+ assertContains "--manifest" positionalFailure+ testPlainCompletion :: Assertion testPlainCompletion = do completion <- completionOutput False 0 []@@ -102,7 +138,9 @@ assertContains "--database-url\tPostgreSQL URI" flags assertContains "--lock-timeout\tWait at most" flags assertContains "--no-wait\tFail immediately" flags- assertContains "--statement-timeout\tPositive PostgreSQL" flags+ assertContains "--wait\tWait indefinitely" flags+ assertContains "--statement-timeout\tSet a positive PostgreSQL" flags+ assertContains "--no-statement-timeout\tRun without" flags assertContains "--json\tEmit JSON schema version 1" flags completionOutput :: Bool -> Int -> [String] -> IO String@@ -158,6 +196,12 @@ let (message, _) = renderFailure failure "pg-migrate-cli-help-fixture" in error message CompletionInvoked _ -> error "unexpected completion"++parsedExecution :: [String] -> ExecutionOptions+parsedExecution arguments =+ case parseSuccess ("up" : arguments) of+ Up UpOptions {execution} -> execution+ parsedCommand -> error ("expected up command, received: " <> show parsedCommand) parseFailure :: [String] -> IO String parseFailure arguments =