wireform-proto-0.2.0.0: src/Proto/Compat.hs
{- | Schema compatibility checking in the style of Confluent Schema Registry.
Confluent defines several compatibility levels for schema evolution:
* __BACKWARD__: new schema can read data written by the old schema.
Consumers can be upgraded before producers.
* __FORWARD__: old schema can read data written by the new schema.
Producers can be upgraded before consumers.
* __FULL__: both backward and forward compatible.
Producers and consumers can be upgraded in any order.
* __BACKWARD_TRANSITIVE__: backward compatible with all prior versions.
* __FORWARD_TRANSITIVE__: forward compatible with all prior versions.
* __FULL_TRANSITIVE__: full compatible with all prior versions.
* __NONE__: no compatibility checking.
For protobuf, this translates to checking specific rules about field
additions, removals, type changes, and number reuse.
Backward-compatible changes (new reads old):
* Add a new optional/repeated field
* Remove a field (must be reserved)
* Change a field from required to optional (proto2)
Forward-compatible changes (old reads new):
* Add a new optional/repeated field (old ignores it)
* Remove an optional/repeated field
Breaking changes:
* Change a field's type (different wire type)
* Change a field's number
* Reuse a previously deleted field number without reserving
* Remove a required field (proto2)
* Add a required field (proto2)
* Change between scalar types with different wire formats
* Rename an enum value that's used for JSON encoding
-}
module Proto.Compat (
-- * Compatibility levels
CompatLevel (..),
-- * Compatibility checking
checkCompat,
checkCompatAll,
-- * Compatibility results
CompatResult (..),
CompatError (..),
Severity (..),
isCompatible,
compatErrors,
-- * Individual checks
checkBackward,
checkForward,
checkFull,
-- * Specific rules
checkMessageCompat,
checkEnumCompat,
checkFieldCompat,
-- * Direction (for specific rule checks)
Direction (..),
) where
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Proto.IDL.AST
import Proto.IDL.Inspect
-- | Compatibility level, matching Confluent Schema Registry semantics.
data CompatLevel
= None
| Backward
| BackwardTransitive
| Forward
| ForwardTransitive
| Full
| FullTransitive
deriving stock (Show, Eq, Ord)
-- | Severity of a compatibility issue.
data Severity = Warning | Error
deriving stock (Show, Eq, Ord)
-- | A single compatibility issue found during schema comparison.
data CompatError = CompatError
{ ceMessage :: !Text
, cePath :: !Text -- e.g. "Person.name" or "Status.ACTIVE"
, ceSeverity :: !Severity
, ceRule :: !Text -- short rule identifier
}
deriving stock (Show, Eq)
-- | The result of a compatibility check, containing zero or more errors.
newtype CompatResult = CompatResult
{ crErrors :: [CompatError]
}
deriving stock (Show, Eq)
instance Semigroup CompatResult where
CompatResult a <> CompatResult b = CompatResult (a <> b)
instance Monoid CompatResult where
mempty = CompatResult []
-- | 'True' when the result contains no errors (warnings are allowed).
isCompatible :: CompatResult -> Bool
isCompatible (CompatResult errs) = not (any (\e -> ceSeverity e == Error) errs)
-- | Extract the list of 'CompatError' entries from a result.
compatErrors :: CompatResult -> [CompatError]
compatErrors = crErrors
-- | Check compatibility between a new schema and the previous version.
checkCompat :: CompatLevel -> ProtoFile -> ProtoFile -> CompatResult
checkCompat level new old = case level of
None -> mempty
Backward -> checkBackward new old
BackwardTransitive -> checkBackward new old
Forward -> checkForward new old
ForwardTransitive -> checkForward new old
Full -> checkFull new old
FullTransitive -> checkFull new old
-- | Check against all previous versions (transitive).
checkCompatAll :: CompatLevel -> ProtoFile -> [ProtoFile] -> CompatResult
checkCompatAll level new olds = case level of
None -> mempty
Backward -> maybe mempty (checkBackward new) (safeHead olds)
BackwardTransitive -> foldMap (checkBackward new) olds
Forward -> maybe mempty (checkForward new) (safeHead olds)
ForwardTransitive -> foldMap (checkForward new) olds
Full -> maybe mempty (checkFull new) (safeHead olds)
FullTransitive -> foldMap (checkFull new) olds
where
safeHead [] = Nothing
safeHead (x : _) = Just x
-- | BACKWARD: can the new schema read data written by the old schema?
checkBackward :: ProtoFile -> ProtoFile -> CompatResult
checkBackward new old = checkMessages BackwardDir new old <> checkEnums BackwardDir new old
-- | FORWARD: can the old schema read data written by the new schema?
checkForward :: ProtoFile -> ProtoFile -> CompatResult
checkForward new old = checkMessages ForwardDir new old <> checkEnums ForwardDir new old
-- | FULL: both backward and forward compatible.
checkFull :: ProtoFile -> ProtoFile -> CompatResult
checkFull new old = checkBackward new old <> checkForward new old
-- | Direction of a compatibility check.
data Direction = BackwardDir | ForwardDir
deriving stock (Show, Eq)
dirLabel :: Direction -> Text
dirLabel BackwardDir = "BACKWARD"
dirLabel ForwardDir = "FORWARD"
-- Message-level checks
checkMessages :: Direction -> ProtoFile -> ProtoFile -> CompatResult
checkMessages dir new old =
let newMsgs = Map.fromList (fmap (\m -> (msgName m, m)) (allMessages new))
oldMsgs = Map.fromList (fmap (\m -> (msgName m, m)) (allMessages old))
in -- Messages present in old but absent in new
foldMap
( \name -> case dir of
BackwardDir -> mempty
ForwardDir ->
makeError
(dirLabel dir <> ": message '" <> name <> "' was removed")
name
Error
"MESSAGE_REMOVED"
)
(Map.keys (Map.difference oldMsgs newMsgs))
<>
-- Messages present in both: check field-level compatibility
Map.foldlWithKey'
( \acc name newMsg ->
case Map.lookup name oldMsgs of
Nothing -> acc
Just oldMsg -> acc <> checkMessageCompat dir name newMsg oldMsg
)
mempty
newMsgs
-- | Check field-level compatibility between two versions of a message definition.
checkMessageCompat :: Direction -> Text -> MessageDef -> MessageDef -> CompatResult
checkMessageCompat dir msgPath newMsg oldMsg =
let newFields = fieldMap newMsg
oldFields = fieldMap oldMsg
newNums = Map.keysSet newFields
oldNums = Map.keysSet oldFields
reserved = reservedNumbers newMsg
in -- Fields removed from old
foldMap
( \num ->
let oldFd = oldFields Map.! num
path = msgPath <> "." <> fieldName oldFd
in case dir of
BackwardDir ->
if num `Set.member` reserved
then mempty
else
makeError
( dirLabel dir
<> ": field '"
<> fieldName oldFd
<> "' (number "
<> T.pack (show num)
<> ") removed without reserving the number"
)
path
Error
"FIELD_REMOVED_NOT_RESERVED"
<> case fieldLabel oldFd of
Just Required ->
makeError
(dirLabel dir <> ": required field '" <> fieldName oldFd <> "' removed")
path
Error
"REQUIRED_FIELD_REMOVED"
_ -> mempty
ForwardDir -> mempty
)
(Set.difference oldNums newNums)
<>
-- Fields added in new
foldMap
( \num ->
let newFd = newFields Map.! num
path = msgPath <> "." <> fieldName newFd
in case dir of
BackwardDir -> case fieldLabel newFd of
Just Required ->
makeError
(dirLabel dir <> ": required field '" <> fieldName newFd <> "' added")
path
Error
"REQUIRED_FIELD_ADDED"
_ -> mempty
ForwardDir -> mempty
)
(Set.difference newNums oldNums)
<>
-- Fields present in both: check type compatibility
Map.foldlWithKey'
( \acc num newFd ->
case Map.lookup num oldFields of
Nothing -> acc
Just oldFd -> acc <> checkFieldCompat dir msgPath newFd oldFd
)
mempty
newFields
<>
-- Number reuse check: field numbers in new that were in old but deleted
-- and then re-added with a different name
foldMap
( \num ->
let newFd = newFields Map.! num
oldFd = oldFields Map.! num
path = msgPath <> "." <> fieldName newFd
in if fieldName newFd /= fieldName oldFd && not (num `Set.member` reserved)
then
makeWarning
( dirLabel dir
<> ": field number "
<> T.pack (show num)
<> " changed name from '"
<> fieldName oldFd
<> "' to '"
<> fieldName newFd
<> "'"
)
path
"FIELD_NAME_CHANGED"
else mempty
)
(Set.intersection newNums oldNums)
-- | Check compatibility of a single field.
checkFieldCompat :: Direction -> Text -> FieldDef -> FieldDef -> CompatResult
checkFieldCompat dir msgPath newFd oldFd =
let path = msgPath <> "." <> fieldName newFd
in -- Type change
( if fieldType newFd /= fieldType oldFd
then
if wireCompatible (fieldType newFd) (fieldType oldFd)
then
makeWarning
( dirLabel dir
<> ": field '"
<> fieldName newFd
<> "' type changed from "
<> showFieldType (fieldType oldFd)
<> " to "
<> showFieldType (fieldType newFd)
<> " (wire-compatible)"
)
path
"FIELD_TYPE_CHANGED_COMPATIBLE"
else
makeError
( dirLabel dir
<> ": field '"
<> fieldName newFd
<> "' type changed from "
<> showFieldType (fieldType oldFd)
<> " to "
<> showFieldType (fieldType newFd)
<> " (wire-INCOMPATIBLE)"
)
path
Error
"FIELD_TYPE_CHANGED_INCOMPATIBLE"
else mempty
)
<>
-- Label change (required -> optional is OK for backward, not for forward)
( case (fieldLabel oldFd, fieldLabel newFd) of
(Just Required, Just Optional) -> case dir of
BackwardDir -> mempty
ForwardDir ->
makeError
( dirLabel dir
<> ": field '"
<> fieldName newFd
<> "' changed from required to optional"
)
path
Error
"REQUIRED_TO_OPTIONAL"
(Just Optional, Just Required) ->
makeError
( dirLabel dir
<> ": field '"
<> fieldName newFd
<> "' changed from optional to required"
)
path
Error
"OPTIONAL_TO_REQUIRED"
(Nothing, Just Required) ->
makeError
( dirLabel dir
<> ": field '"
<> fieldName newFd
<> "' changed to required"
)
path
Error
"BECAME_REQUIRED"
_ -> mempty
)
-- Enum-level checks
checkEnums :: Direction -> ProtoFile -> ProtoFile -> CompatResult
checkEnums dir new old =
let newEnums = Map.fromList (fmap (\e -> (enumName e, e)) (allEnums new))
oldEnums = Map.fromList (fmap (\e -> (enumName e, e)) (allEnums old))
in Map.foldlWithKey'
( \acc name newEnum ->
case Map.lookup name oldEnums of
Nothing -> acc
Just oldEnum -> acc <> checkEnumCompat dir name newEnum oldEnum
)
mempty
newEnums
-- | Check compatibility of an enum definition.
checkEnumCompat :: Direction -> Text -> EnumDef -> EnumDef -> CompatResult
checkEnumCompat dir enumPath newEnum oldEnum =
let newVals = Map.fromList (fmap (\v -> (evNumber v, evName v)) (enumValues newEnum))
oldVals = Map.fromList (fmap (\v -> (evNumber v, evName v)) (enumValues oldEnum))
newNums = Map.keysSet newVals
oldNums = Map.keysSet oldVals
in -- Values removed
foldMap
( \num ->
let name = oldVals Map.! num
path = enumPath <> "." <> name
in case dir of
BackwardDir ->
makeError
( dirLabel dir
<> ": enum value '"
<> name
<> "' (number "
<> T.pack (show num)
<> ") removed"
)
path
Error
"ENUM_VALUE_REMOVED"
ForwardDir -> mempty
)
(Set.difference oldNums newNums)
<>
-- Values added
foldMap
( \num ->
let name = newVals Map.! num
path = enumPath <> "." <> name
in case dir of
ForwardDir ->
makeWarning
( dirLabel dir
<> ": enum value '"
<> name
<> "' (number "
<> T.pack (show num)
<> ") added; "
<> "old readers will see the numeric value"
)
path
"ENUM_VALUE_ADDED"
BackwardDir -> mempty
)
(Set.difference newNums oldNums)
<>
-- Values renamed (same number, different name) — problematic for JSON encoding
foldMap
( \num ->
let newName = newVals Map.! num
oldName = oldVals Map.! num
path = enumPath <> "." <> newName
in if newName /= oldName
then
makeWarning
( dirLabel dir
<> ": enum value at number "
<> T.pack (show num)
<> " renamed from '"
<> oldName
<> "' to '"
<> newName
<> "' (breaks JSON compatibility)"
)
path
"ENUM_VALUE_RENAMED"
else mempty
)
(Set.intersection newNums oldNums)
-- Wire compatibility: two types are wire-compatible if they use the same
-- wire format on the wire. This means data written with one type can be
-- read (if perhaps misinterpreted) with the other.
wireCompatible :: FieldType -> FieldType -> Bool
wireCompatible a b = wireType a == wireType b
data WireGroup = WGVarint | WG64Bit | WG32Bit | WGLenDelim
deriving stock (Eq)
wireType :: FieldType -> WireGroup
wireType = \case
FTScalar SDouble -> WG64Bit
FTScalar SFloat -> WG32Bit
FTScalar SInt32 -> WGVarint
FTScalar SInt64 -> WGVarint
FTScalar SUInt32 -> WGVarint
FTScalar SUInt64 -> WGVarint
FTScalar SSInt32 -> WGVarint
FTScalar SSInt64 -> WGVarint
FTScalar SFixed32 -> WG32Bit
FTScalar SFixed64 -> WG64Bit
FTScalar SSFixed32 -> WG32Bit
FTScalar SSFixed64 -> WG64Bit
FTScalar SBool -> WGVarint
FTScalar SString -> WGLenDelim
FTScalar SBytes -> WGLenDelim
FTNamed _ -> WGLenDelim
-- Helpers
fieldMap :: MessageDef -> Map Int FieldDef
fieldMap msg =
Map.fromList
(fmap (\fd -> (unFieldNumber (fieldNumber fd), fd)) (messageFields msg))
reservedNumbers :: MessageDef -> Set Int
reservedNumbers msg = Set.fromList $ concatMap go (msgElements msg)
where
go (MEReserved (ReservedNumbers ranges)) = concatMap expandRange ranges
go _ = []
expandRange (ReservedSingle n) = [n]
expandRange (ReservedRange lo hi) = [lo .. hi]
showFieldType :: FieldType -> Text
showFieldType = \case
FTScalar s -> showScalar s
FTNamed n -> n
showScalar :: ScalarType -> Text
showScalar = \case
SDouble -> "double"
SFloat -> "float"
SInt32 -> "int32"
SInt64 -> "int64"
SUInt32 -> "uint32"
SUInt64 -> "uint64"
SSInt32 -> "sint32"
SSInt64 -> "sint64"
SFixed32 -> "fixed32"
SFixed64 -> "fixed64"
SSFixed32 -> "sfixed32"
SSFixed64 -> "sfixed64"
SBool -> "bool"
SString -> "string"
SBytes -> "bytes"
makeError :: Text -> Text -> Severity -> Text -> CompatResult
makeError msg path sev rule = CompatResult [CompatError msg path sev rule]
makeWarning :: Text -> Text -> Text -> CompatResult
makeWarning msg path rule = CompatResult [CompatError msg path Warning rule]