pgmq-migration-0.4.0.0: src/Pgmq/Migration/SchemaContract.hs
{-# LANGUAGE LambdaCase #-}
-- | Read-only validation of the PGMQ 1.11 database objects consumed by pgmq-hs.
module Pgmq.Migration.SchemaContract
( pgmqV1_11StateValidator,
pgmqV1_11StateEvidenceKey,
)
where
import Data.Aeson qualified as Aeson
import Data.Text (Text)
import Data.Text qualified as Text
import Database.PostgreSQL.Migrate
( EvidenceKey,
StateValidator,
evidenceKey,
stateValidationError,
stateValidator,
)
import Hasql.Decoders qualified as Decoders
import Hasql.Encoders qualified as Encoders
import Hasql.Statement (Statement)
import Hasql.Statement qualified as Statement
import Hasql.Transaction qualified as Transaction
-- | Stable evidence key required by the equivalent two-step import route.
pgmqV1_11StateEvidenceKey :: EvidenceKey
pgmqV1_11StateEvidenceKey =
requireDefinition "PGMQ 1.11 state evidence key" (evidenceKey "pgmq_schema_contract_v1.11")
-- | Validate the PGMQ 1.11 objects used by pgmq-hs without changing database state.
pgmqV1_11StateValidator :: StateValidator
pgmqV1_11StateValidator =
stateValidator pgmqV1_11StateEvidenceKey $ do
missing <- Transaction.statement () missingContractObjectsStatement
pure $ case missing of
[] ->
Right
( Aeson.object
[ "contract" Aeson..= ("pgmq-1.11" :: Text),
"checked_objects" Aeson..= length requiredContractObjects
]
)
_ ->
Left
( requireDefinition
"PGMQ 1.11 state validation diagnostic"
( stateValidationError
("PGMQ 1.11 schema contract is missing: " <> Text.intercalate ", " missing)
)
)
data RequiredObject
= RequiredSchema !Text
| RequiredTable !Text
| RequiredColumn !Text !Text !Text !Bool
| RequiredConstraint !Text !Text !Char
| RequiredType !Text
| RequiredFunction !Text
-- Reviewed against vendor/pgmq/pgmq-extension/sql/pgmq.sql at PGMQ v1.11.0.
-- Function identities use PostgreSQL's regprocedure input notation.
requiredContractObjects :: [RequiredObject]
requiredContractObjects =
[ RequiredSchema "pgmq",
RequiredTable "meta",
RequiredColumn "meta" "queue_name" "character varying" True,
RequiredColumn "meta" "is_partitioned" "boolean" True,
RequiredColumn "meta" "is_unlogged" "boolean" True,
RequiredColumn "meta" "created_at" "timestamp with time zone" True,
RequiredConstraint "meta" "meta_queue_name_key" 'u',
RequiredTable "topic_bindings",
RequiredColumn "topic_bindings" "pattern" "text" True,
RequiredColumn "topic_bindings" "queue_name" "text" True,
RequiredColumn "topic_bindings" "bound_at" "timestamp with time zone" True,
RequiredColumn "topic_bindings" "compiled_regex" "text" False,
RequiredConstraint "topic_bindings" "topic_bindings_meta_queue_name_fk" 'f',
RequiredConstraint "topic_bindings" "topic_bindings_unique_pattern_queue" 'u',
RequiredType "message_record",
RequiredType "queue_record",
RequiredType "metrics_result"
]
<> fmap RequiredFunction requiredFunctions
requiredFunctions :: [Text]
requiredFunctions =
[ "pgmq.archive(text,bigint)",
"pgmq.archive(text,bigint[])",
"pgmq.bind_topic(text,text)",
"pgmq.create(text)",
"pgmq.create_fifo_index(text)",
"pgmq.create_fifo_indexes_all()",
"pgmq.create_partitioned(text,text,text)",
"pgmq.create_unlogged(text)",
"pgmq.delete(text,bigint)",
"pgmq.delete(text,bigint[])",
"pgmq.detach_archive(text)",
"pgmq.disable_notify_insert(text)",
"pgmq.drop_queue(text)",
"pgmq.enable_notify_insert(text,integer)",
"pgmq.list_notify_insert_throttles()",
"pgmq.list_queues()",
"pgmq.list_topic_bindings()",
"pgmq.list_topic_bindings(text)",
"pgmq.metrics(text)",
"pgmq.metrics_all()",
"pgmq.pop(text,integer)",
"pgmq.purge_queue(text)",
"pgmq.read(text,integer,integer,jsonb)",
"pgmq.read_grouped(text,integer,integer)",
"pgmq.read_grouped_rr(text,integer,integer)",
"pgmq.read_grouped_rr_with_poll(text,integer,integer,integer,integer)",
"pgmq.read_grouped_with_poll(text,integer,integer,integer,integer)",
"pgmq.read_with_poll(text,integer,integer,integer,integer,jsonb)",
"pgmq.send(text,jsonb)",
"pgmq.send(text,jsonb,integer)",
"pgmq.send(text,jsonb,timestamp with time zone)",
"pgmq.send(text,jsonb,jsonb)",
"pgmq.send(text,jsonb,jsonb,integer)",
"pgmq.send(text,jsonb,jsonb,timestamp with time zone)",
"pgmq.send_batch(text,jsonb[])",
"pgmq.send_batch(text,jsonb[],integer)",
"pgmq.send_batch(text,jsonb[],timestamp with time zone)",
"pgmq.send_batch(text,jsonb[],jsonb[])",
"pgmq.send_batch(text,jsonb[],jsonb[],integer)",
"pgmq.send_batch(text,jsonb[],jsonb[],timestamp with time zone)",
"pgmq.send_batch_topic(text,jsonb[])",
"pgmq.send_batch_topic(text,jsonb[],integer)",
"pgmq.send_batch_topic(text,jsonb[],timestamp with time zone)",
"pgmq.send_batch_topic(text,jsonb[],jsonb[])",
"pgmq.send_batch_topic(text,jsonb[],jsonb[],integer)",
"pgmq.send_batch_topic(text,jsonb[],jsonb[],timestamp with time zone)",
"pgmq.send_topic(text,jsonb)",
"pgmq.send_topic(text,jsonb,integer)",
"pgmq.send_topic(text,jsonb,jsonb,integer)",
"pgmq.set_vt(text,bigint,integer)",
"pgmq.set_vt(text,bigint,timestamp with time zone)",
"pgmq.set_vt(text,bigint[],integer)",
"pgmq.set_vt(text,bigint[],timestamp with time zone)",
"pgmq.test_routing(text)",
"pgmq.unbind_topic(text,text)",
"pgmq.update_notify_insert(text,integer)",
"pgmq.validate_routing_key(text)",
"pgmq.validate_topic_pattern(text)"
]
missingContractObjectsStatement :: Statement () [Text]
missingContractObjectsStatement =
Statement.preparable
contractQuery
Encoders.noParams
(Decoders.rowList (Decoders.column (Decoders.nonNullable Decoders.text)))
contractQuery :: Text
contractQuery =
"SELECT required.identity\n"
<> "FROM (VALUES\n "
<> Text.intercalate ",\n " (renderRequiredObject <$> requiredContractObjects)
<> "\n) AS required(identity, present)\n"
<> "WHERE NOT required.present\n"
<> "ORDER BY required.identity"
renderRequiredObject :: RequiredObject -> Text
renderRequiredObject = \case
RequiredSchema schemaName ->
row ("schema:" <> schemaName) ("pg_catalog.to_regnamespace(" <> literal schemaName <> ") IS NOT NULL")
RequiredTable tableName ->
row
("table:pgmq." <> tableName)
("pg_catalog.to_regclass(" <> literal ("pgmq." <> tableName) <> ") IS NOT NULL")
RequiredColumn tableName columnName typeName requiredNotNull ->
row
("column:pgmq." <> tableName <> "." <> columnName)
( "EXISTS (SELECT 1 FROM pg_catalog.pg_attribute AS attribute "
<> "JOIN pg_catalog.pg_class AS relation ON relation.oid = attribute.attrelid "
<> "JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace "
<> "WHERE namespace.nspname = 'pgmq' AND relation.relname = "
<> literal tableName
<> " AND attribute.attname = "
<> literal columnName
<> " AND attribute.attnum > 0 AND NOT attribute.attisdropped "
<> "AND pg_catalog.format_type(attribute.atttypid, attribute.atttypmod) = "
<> literal typeName
<> if requiredNotNull then " AND attribute.attnotnull)" else ")"
)
RequiredConstraint tableName constraintName constraintType ->
row
("constraint:pgmq." <> tableName <> "." <> constraintName)
( "EXISTS (SELECT 1 FROM pg_catalog.pg_constraint AS constraint_record "
<> "JOIN pg_catalog.pg_class AS relation ON relation.oid = constraint_record.conrelid "
<> "JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace "
<> "WHERE namespace.nspname = 'pgmq' AND relation.relname = "
<> literal tableName
<> " AND constraint_record.conname = "
<> literal constraintName
<> " AND constraint_record.contype = "
<> literal (Text.singleton constraintType)
<> ")"
)
RequiredType typeName ->
row
("type:pgmq." <> typeName)
("pg_catalog.to_regtype(" <> literal ("pgmq." <> typeName) <> ") IS NOT NULL")
RequiredFunction functionIdentity ->
row
("function:" <> functionIdentity)
("pg_catalog.to_regprocedure(" <> literal functionIdentity <> ") IS NOT NULL")
row :: Text -> Text -> Text
row identity expression = "(" <> literal identity <> ", " <> expression <> ")"
literal :: Text -> Text
literal value = "'" <> Text.replace "'" "''" value <> "'"
requireDefinition :: String -> Either error value -> value
requireDefinition label = either (const (error (label <> " is invalid"))) id