keiro-dsl-0.17.0.0: src/Keiro/Dsl/NominalType.hs
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
-- | Checked nominal declarations shared by validation, aggregate lowering,
-- generation, compatibility analysis, and scaffold records.
--
-- Parser declarations deliberately retain optional facts so diagnostics can be
-- located at their owner. This module is the phase boundary after which every
-- consumer binding is complete, every representation is closed, and every name
-- has one declaration category.
module Keiro.Dsl.NominalType
( NominalScalarRepresentation (..),
NominalRepresentation (..),
NominalEqualityKey (..),
NominalEqualityDomain (..),
CheckedNominalEquality (..),
NominalOwnership (..),
ConsumerNominalBinding (..),
ResolvedNominalType (..),
NominalTypeRegistry,
nominalTypes,
lookupNominalType,
nominalEqualityContractForService,
nominalEqualityIdentityForService,
nominalEqualityIdentitiesForService,
NominalTypeError (..),
resolveNominalTypes,
)
where
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Keiro.Dsl.Grammar
import Keiro.Dsl.IdDomain (enforcedIdDomainVersion)
import Keiro.Dsl.LanguageVersion (RuntimeCapability (..), runtimeProfileHasCapability)
import Keiro.Dsl.SemanticContract (CheckedService, EffectiveLanguageContract (..), checkedLanguageContract, checkedSpec)
import Keiro.Dsl.TypeGraph
data NominalRepresentation
= IdRepresentation !Text
| EnumRepresentation !(NonEmpty (Name, Text))
| ScalarRepresentation !NominalScalarRepresentation
deriving stock (Eq, Ord, Show, Generic)
-- | The canonical carrier compared by generated nominal equality. IDs and
-- enums deliberately share their stable textual wire key while remaining
-- type-distinct in the checked expression tree.
data NominalEqualityKey
= NominalTextEqualityKey
deriving stock (Eq, Ord, Show, Generic)
-- | The exactness Keiro can honestly claim for a nominal equality projection.
-- Released generated IDs still wrap arbitrary 'Text', so their projection is
-- total but unconstrained until the enforcing language contract lands. A
-- consumer-bound ID is backed by a checked @KindID prefix@ representation and
-- therefore has the exact TypeID text image. Enums always have a finite image.
data NominalEqualityDomain
= LegacyUnrestrictedTextDomain
| TypeIdTextDomain !Text
| EnforcedTypeIdV7TextDomain !Text !Text
| FiniteTextDomain !(NonEmpty Text)
deriving stock (Eq, Ord, Show, Generic)
data CheckedNominalEquality = CheckedNominalEquality
{ keyRepresentation :: !NominalEqualityKey,
domain :: !NominalEqualityDomain,
contractVersion :: !Text
}
deriving stock (Eq, Ord, Show, Generic)
data NominalOwnership
= GeneratedNominal
| ConsumerNominal !ConsumerNominalBinding
deriving stock (Eq, Ord, Show, Generic)
data ResolvedNominalType = ResolvedNominalType
{ name :: !Name,
representation :: !NominalRepresentation,
ownership :: !NominalOwnership,
loc :: !Loc
}
deriving stock (Eq, Show, Generic)
instance Ord ResolvedNominalType where
compare left right =
compare
((.name) left, (.representation) left, (.ownership) left)
((.name) right, (.representation) right, (.ownership) right)
newtype NominalTypeRegistry = NominalTypeRegistry (Map Name ResolvedNominalType)
deriving stock (Eq, Show, Generic)
nominalTypes :: NominalTypeRegistry -> Map Name ResolvedNominalType
nominalTypes (NominalTypeRegistry values) = values
lookupNominalType :: Name -> NominalTypeRegistry -> Maybe ResolvedNominalType
lookupNominalType name = Map.lookup name . nominalTypes
nominalEqualityContractForService :: EffectiveLanguageContract -> ResolvedNominalType -> Maybe CheckedNominalEquality
nominalEqualityContractForService languageContract nominal = case (.representation) nominal of
IdRepresentation prefix ->
Just
CheckedNominalEquality
{ keyRepresentation = NominalTextEqualityKey,
domain =
if enforcesNominalEqualityV2
then EnforcedTypeIdV7TextDomain prefix enforcedIdDomainVersion
else case (.ownership) nominal of
GeneratedNominal -> LegacyUnrestrictedTextDomain
ConsumerNominal {} -> TypeIdTextDomain prefix,
contractVersion =
if enforcesNominalEqualityV2
then "keiro-dsl/nominal-equality/2"
else nominalEqualityContractVersion
}
EnumRepresentation constructors ->
Just
CheckedNominalEquality
{ keyRepresentation = NominalTextEqualityKey,
domain = FiniteTextDomain (snd <$> constructors),
contractVersion = nominalEqualityContractVersion
}
ScalarRepresentation {} -> Nothing
where
enforcesNominalEqualityV2 =
runtimeProfileHasCapability ((.runtimeProfile) languageContract) NominalEqualityV2
-- | Stable, checked identity used by generated projection tags, fingerprints,
-- scaffold history, and explain output. It includes the existing binding
-- authority rather than introducing a second consumer equality function.
nominalEqualityIdentityForService :: EffectiveLanguageContract -> ResolvedNominalType -> Maybe Text
nominalEqualityIdentityForService languageContract nominal = do
equality <- nominalEqualityContractForService languageContract nominal
pure . T.intercalate "|" $
[ "nominal-equality",
"name=" <> (.name) nominal,
"contract=" <> (.contractVersion) equality,
"key=" <> renderEqualityKey ((.keyRepresentation) equality),
"domain=" <> renderEqualityDomain ((.domain) equality),
renderOwnership ((.ownership) nominal)
]
where
renderEqualityKey NominalTextEqualityKey = "Text"
renderEqualityDomain LegacyUnrestrictedTextDomain = "legacy-unrestricted-text"
renderEqualityDomain (TypeIdTextDomain prefix) = "typeid-text:" <> prefix
renderEqualityDomain (EnforcedTypeIdV7TextDomain prefix contractVersion) =
"typeid-v7-text:" <> prefix <> ":" <> contractVersion
renderEqualityDomain (FiniteTextDomain values) = "finite-text:" <> T.intercalate "," (NE.toList values)
renderOwnership GeneratedNominal = "owner=generated"
renderOwnership (ConsumerNominal binding) =
T.intercalate
";"
[ "owner=consumer",
"canonical=" <> unCanonicalTypeId ((.canonical) binding),
"binding=" <> unQualifiedValueName ((.binding) binding),
"binding-version=" <> unBindingVersion ((.bindingVersion) binding)
]
nominalEqualityIdentitiesForService :: CheckedService -> [Text]
nominalEqualityIdentitiesForService service = case resolveNominalTypes spec of
Left _ -> []
Right registry ->
[ identity
| nominal <- Map.elems (nominalTypes registry),
Just identity <- [nominalEqualityIdentityForService (checkedLanguageContract service) nominal]
]
where
spec = checkedSpec service
nominalEqualityContractVersion :: Text
nominalEqualityContractVersion = "keiro-dsl/nominal-equality/1"
data NominalTypeError
= NominalMissingIngredient !Name !Loc !Text
| NominalInvalidHaskellSource !Name !Loc !Text
| NominalInvalidQualifiedValue !Name !Loc !Text !Text
| NominalInvalidIdentity !Name !Loc !Text !Text
| NominalInvalidIdPrefix !Name !Loc !Text !Text
| NominalUnsupportedScalar !Name !Loc !Name
| NominalEmptyEnum !Name !Loc
| NominalMissingRegisterInitial !Name !Loc !Name
| NominalDeclarationCollision !Name !Loc ![Text]
deriving stock (Eq, Show, Generic)
resolveNominalTypes :: Spec -> Either (NonEmpty NominalTypeError) NominalTypeRegistry
resolveNominalTypes spec = do
resolved <- rejectErrors declarationErrors resolvedDeclarations
rejectMany collisionErrors
let registry = NominalTypeRegistry (Map.fromList [((.name) value, value) | value <- resolved])
rejectMany (registerInitialErrors registry)
pure registry
where
declarationResults =
map resolveId ((.ids) spec)
<> map resolveEnum ((.enums) spec)
<> map resolveScalar ((.nominalScalars) spec)
declarationErrors = concatMap fst declarationResults
resolvedDeclarations = [value | (_, Just value) <- declarationResults]
resolveId declaration = case checkIdLeaf declaration of
Left leafError -> (map leafIssueToNominalError (NE.toList ((.nominalLeafIssues) leafError)), Nothing)
Right leaf -> ([], Just (resolvedFromLeaf leaf))
resolveEnum declaration = case checkEnumLeaf declaration of
Left leafError -> (map leafIssueToNominalError (NE.toList ((.nominalLeafIssues) leafError)), Nothing)
Right leaf -> ([], Just (resolvedFromLeaf leaf))
resolveScalar declaration = case checkScalarLeaf declaration of
Left leafError -> (map leafIssueToNominalError (NE.toList ((.nominalLeafIssues) leafError)), Nothing)
Right leaf -> ([], Just (resolvedFromLeaf leaf))
collisionErrors =
[ NominalDeclarationCollision name loc categories
| (name, occurrences) <- Map.toList originsByName,
let categories = map fst occurrences,
Set.size (Set.fromList categories) > 1,
(_, loc) <- occurrences
]
originsByName = Map.fromListWith (<>) [(name, [(category, loc)]) | (name, category, loc) <- origins]
origins =
[((.name) value, "id", (.loc) value) | value <- (.ids) spec]
<> [((.name) value, "enum", (.loc) value) | value <- (.enums) spec]
<> [((.name) value, "nominal scalar", (.loc) value) | value <- (.nominalScalars) spec]
<> [(mappedName value, "mapped", mappedLoc value) | value <- (.mapped) spec]
<> [((.name) value, "rule", (.loc) value) | value <- (.rules) spec]
<> [(name, kind <> " node", loc) | node <- (.nodes) spec, let (kind, name, loc) = nodeIdentityLocal node]
registerInitialErrors registry =
[ NominalMissingRegisterInitial typeName ((.loc) register) ((.name) register)
| aggregate <- [value | NAggregate value <- (.nodes) spec],
register <- (.regs) aggregate,
TRef typeName <- [(.valueType) register],
Just resolved <- [lookupNominalType typeName registry],
ConsumerNominal binding <- [(.ownership) resolved],
(.initial) binding == Nothing
]
resolvedFromLeaf :: NominalLeaf -> ResolvedNominalType
resolvedFromLeaf leaf =
ResolvedNominalType
{ name = (.name) leaf,
representation = case (.kind) leaf of
NominalIdLeaf prefix -> IdRepresentation prefix
NominalEnumLeaf constructors -> EnumRepresentation constructors
NominalScalarLeaf representation -> ScalarRepresentation representation,
ownership = case (.ownership) leaf of
GeneratedLeaf -> GeneratedNominal
ConsumerLeaf binding -> ConsumerNominal binding,
loc = (.loc) leaf
}
leafIssueToNominalError :: NominalLeafIssue -> NominalTypeError
leafIssueToNominalError = \case
LeafMissingIngredient name loc label -> NominalMissingIngredient name loc label
LeafInvalidHaskellSource name loc label -> NominalInvalidHaskellSource name loc label
LeafInvalidQualifiedValue name loc category value -> NominalInvalidQualifiedValue name loc category value
LeafInvalidIdentity name loc category value -> NominalInvalidIdentity name loc category value
LeafInvalidIdPrefix name loc prefix reason -> NominalInvalidIdPrefix name loc prefix reason
LeafEmptyEnum name loc -> NominalEmptyEnum name loc
LeafUnsupportedScalar name loc representation -> NominalUnsupportedScalar name loc representation
rejectErrors :: [e] -> [a] -> Either (NonEmpty e) [a]
rejectErrors errors values = maybe (Right values) Left (NE.nonEmpty errors)
rejectMany :: [e] -> Either (NonEmpty e) ()
rejectMany errors = maybe (Right ()) Left (NE.nonEmpty errors)
mappedName :: MappedDecl -> Name
mappedName MappedStructural {msName = name} = name
mappedName MappedOpaque {moName = name} = name
mappedLoc :: MappedDecl -> Loc
mappedLoc MappedStructural {msLoc = loc} = loc
mappedLoc MappedOpaque {moLoc = loc} = loc
nodeIdentityLocal :: Node -> (Text, Name, Loc)
nodeIdentityLocal = \case
NAggregate value -> ("aggregate", (.name) value, (.loc) value)
NProcess value -> ("process", (.id) value, (.loc) value)
NRouter value -> ("router", (.id) value, (.loc) value)
NContract value -> ("contract", (.name) value, (.loc) value)
NIntake value -> ("intake", (.name) value, (.loc) value)
NEmit value -> ("emit", (.name) value, (.loc) value)
NPublisher value -> ("publisher", (.name) value, (.loc) value)
NWorkqueue value -> ("workqueue", (.name) value, (.loc) value)
NPgmqDispatch value -> ("dispatch", (.name) value, (.loc) value)
NReadModel value -> ("readmodel", (.name) value, (.loc) value)
NProjectionTarget value -> ("target", (.name) value, (.loc) value)
NRebuildGroup value -> ("rebuild-group", (.name) value, (.loc) value)
NProjectionRevision value -> ("projection-revision", (.name) value, (.loc) value)
NExternalRead value -> ("external-read", externalReadNodeIdentity value, (.loc) value)
NProjectionOwner value -> ("projection-owner", (.name) value, (.loc) value)
NWorkflow value -> ("workflow", (.id) value, workflowNodeLoc value)
NOperation value -> ("operation", (.name) value, (.loc) value)