compaREST-0.1.0.0: src/Data/OpenApi/Compare/Validate/Schema/Partition.hs
module Data.OpenApi.Compare.Validate.Schema.Partition
( partitionSchema,
partitionRefSchema,
selectPartition,
runPartitionM,
tryPartition,
showPartition,
intersectSchema,
intersectRefSchema,
IntersectionResult (..),
runIntersectionM,
Partition,
)
where
import Algebra.Lattice
import Algebra.Lattice.Lifted
import Control.Applicative
import Control.Monad.Reader hiding (ask)
import qualified Control.Monad.Reader as R
import Control.Monad.State
import qualified Control.Monad.Trans.Reader as R (liftCatch)
import qualified Control.Monad.Trans.Writer as W (liftCatch)
import Control.Monad.Writer
import qualified Data.Aeson as A
import Data.Foldable
import Data.Functor.Identity
import qualified Data.HashMap.Strict.InsOrd as IOHM
import Data.List (sortBy)
import qualified Data.List as L
import qualified Data.List.NonEmpty as NE
import qualified Data.Map as M
import Data.Maybe
import Data.OpenApi
import Data.OpenApi.Compare.Memo
import Data.OpenApi.Compare.References
import Data.OpenApi.Compare.Subtree
import Data.OpenApi.Compare.Validate.Schema.DNF
import Data.OpenApi.Compare.Validate.Schema.JsonFormula
import Data.OpenApi.Compare.Validate.Schema.Traced
import Data.OpenApi.Compare.Validate.Schema.TypedJson
import Data.Ord
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Text.Pandoc.Builder hiding (Format, Null)
data PartitionData
= DByEnumValue (DNF (S.Set A.Value))
| DByProperties (DNF (S.Set Text, S.Set Text)) -- optional, required
deriving stock (Eq, Ord, Show)
conjPart :: PartitionData -> PartitionData -> Maybe PartitionData
conjPart (DByEnumValue xss) (DByEnumValue yss) = Just . DByEnumValue $ xss /\ yss
conjPart (DByProperties xss) (DByProperties yss) = Just . DByProperties $ xss /\ yss
conjPart _ _ = Nothing
disjPart :: PartitionData -> PartitionData -> Maybe PartitionData
disjPart (DByEnumValue xss) (DByEnumValue yss) = Just . DByEnumValue $ xss \/ yss
disjPart (DByProperties xss) (DByProperties yss) = Just . DByProperties $ xss \/ yss
disjPart _ _ = Nothing
newtype Partitions = Partitions (M.Map PartitionLocation (S.Set PartitionData))
deriving stock (Eq, Ord, Show)
instance Lattice Partitions where
Partitions xss /\ Partitions yss = Partitions $ M.unionWith conj xss yss
where
conj xs ys = S.fromList . catMaybes $ liftA2 conjPart (S.toList xs) (S.toList ys)
Partitions xss \/ Partitions yss = Partitions $ M.intersectionWith disj xss yss
where
disj xs ys = S.fromList . catMaybes $ liftA2 disjPart (S.toList xs) (S.toList ys)
instance BoundedMeetSemiLattice Partitions where
top = Partitions M.empty
-- The lattice has no bottom, but we use 'Lifted' to adjoin a free bottom element
type PartitionM = ReaderT (Traced (Definitions Schema)) (State (MemoState ()))
ignoreKnot :: KnotTier (Lifted Partitions) () PartitionM
ignoreKnot =
KnotTier
{ onKnotFound = pure ()
, onKnotUsed = \_ -> pure bottom
, tieKnot = \_ -> pure
}
singletonPart :: PartitionData -> Lifted Partitions
singletonPart = Lift . Partitions . M.singleton PHere . S.singleton
partitionSchema :: Traced Schema -> PartitionM (Lifted Partitions)
partitionSchema sch = do
allClauses <- case tracedAllOf sch of
Nothing -> pure []
Just xs -> mapM partitionRefSchema xs
anyClause <- case tracedAnyOf sch of
Nothing -> pure top
Just xs -> joins <$> mapM partitionRefSchema xs
oneClause <- case tracedOneOf sch of
Nothing -> pure top
Just xs -> joins <$> mapM partitionRefSchema xs
byEnumClause <- case _schemaEnum $ extract sch of
Nothing -> pure top
Just xs ->
pure . singletonPart $
DByEnumValue $ LiteralDNF (S.fromList xs)
-- We can only partition by presence of a property if additional properties
-- are disallowed, and the property is not optional
let reqd = S.fromList $ _schemaRequired $ extract sch
byPropertiesClause <- case _schemaAdditionalProperties $ extract sch of
Just (AdditionalPropertiesAllowed False) -> do
let props = S.fromList . IOHM.keys . _schemaProperties $ extract sch
pure . singletonPart $
DByProperties $ LiteralDNF (props S.\\ reqd, props `S.intersection` reqd)
_ -> pure top
-- We can partition on something nested in a property only if the property is
-- required
let reqdProps = IOHM.filterWithKey (\k _ -> k `S.member` reqd) $ tracedProperties sch
inPropertiesClauses <- forM (IOHM.toList reqdProps) $ \(k, rs) -> do
f <- partitionRefSchema rs
pure $ fmap (\(Partitions m) -> Partitions $ M.mapKeysMonotonic (PInProperty k) m) f
pure $ meets $ allClauses <> [anyClause, oneClause, byEnumClause, byPropertiesClause] <> inPropertiesClauses
partitionRefSchema :: Traced (Referenced Schema) -> PartitionM (Lifted Partitions)
partitionRefSchema x = do
defs <- R.ask
memoWithKnot ignoreKnot (partitionSchema $ dereference defs x) (ask x)
partitionCondition :: Condition t -> PartitionM (Lifted Partitions)
partitionCondition = \case
Exactly x ->
pure . singletonPart $
DByEnumValue $ LiteralDNF (S.singleton $ untypeValue x)
Properties props _ madd -> do
let byProps = case madd of
Just _ -> top
Nothing ->
singletonPart $
DByProperties $
LiteralDNF
( M.keysSet $ M.filter (not . propRequired) props
, M.keysSet $ M.filter propRequired props
)
inProps <- forM (M.toList $ M.filter propRequired props) $ \(k, prop) -> do
f <- partitionRefSchema $ propRefSchema prop
pure $ fmap (\(Partitions m) -> Partitions $ M.mapKeysMonotonic (PInProperty k) m) f
pure $ byProps /\ meets inProps
_ -> pure top
runPartitionM :: Traced (Definitions Schema) -> PartitionM a -> a
runPartitionM defs = runIdentity . runMemo () . (`runReaderT` defs)
partitionJsonFormulas ::
ProdCons (Traced (Definitions Schema)) ->
ProdCons (JsonFormula t) ->
Lifted Partitions
partitionJsonFormulas defs pc = producer pcPart \/ consumer pcPart
where
pcPart = partitionFormula <$> defs <*> pc
partitionFormula def (JsonFormula xss) = runPartitionM def $ forDNF partitionCondition xss
selectPartition :: Lifted Partitions -> Maybe (PartitionLocation, S.Set PartitionChoice)
selectPartition Bottom = Nothing
selectPartition (Lift (Partitions m)) =
go [(loc, part) | (loc, parts) <- sortBy (comparing $ locLength . fst) $ M.toList m, part <- S.toList parts]
where
locLength :: PartitionLocation -> Int
locLength = walk 0
where
walk !n PHere = n
walk !n (PInProperty _ l) = walk (n + 1) l
go [] = Nothing
-- Skip partitioning by property for now
go ((_, DByProperties _) : ps) = go ps
-- Don't partition by enum value at the root (this reports removed enum values as contradictions in their respective partitions)
go ((PHere, DByEnumValue _) : ps) = go ps
go ((loc, DByEnumValue (DNF xss)) : ps)
-- Check that no disjunction branches are unresticted
| Just enums <- traverse (\(Disjunct xs) -> fmap (foldr1 S.intersection) . NE.nonEmpty . S.toList $ xs) . S.toList $ xss =
-- TODO: improve
Just (loc, S.map (CByEnumValue . S.singleton) $ S.unions enums)
| otherwise = go ps
-- This essentially has 3 cases:
-- Nothing -- we have produced a bottom schema
-- Just (False, _) -- there's been no change to the schema
-- Just (True, x) -- x is a new schema
type IntersectionM = ReaderT (Traced (Definitions Schema)) (WriterT Any Maybe)
mBottom :: IntersectionM a
mBottom = lift . lift $ Nothing
catchBottom :: IntersectionM a -> IntersectionM a -> IntersectionM a
catchBottom act handler = R.liftCatch (W.liftCatch (\a h -> a <|> h ())) act (\_ -> handler)
mChange :: IntersectionM ()
mChange = tell $ Any True
data IntersectionResult a = Disjoint | Same a | New a
deriving stock (Eq, Ord, Show)
runIntersectionM :: Traced (Definitions Schema) -> IntersectionM a -> IntersectionResult a
runIntersectionM defs act = case runWriterT $ runReaderT act defs of
Nothing -> Disjoint
Just (x, Any False) -> Same x
Just (x, Any True) -> New x
intersectSchema ::
PartitionLocation ->
PartitionChoice ->
Traced Schema ->
IntersectionM Schema
intersectSchema loc part sch = do
allOf' <- forM (tracedAllOf sch) $ \rss ->
-- Assuming i ranges over a nonempty set (checked in processSchema)
-- (⋂_i A[i]) ∩ X = ⋂_i (A[i] ∩ X)
-- If any intersections are empty, the result is empty. If any intersections are a change, the result is a change.
traverse (intersectRefSchema loc part) rss
anyOf' <- forM (tracedAnyOf sch) $ \rss -> do
-- (⋃_i A[i]) ∩ X = ⋃_i (A[i] ∩ X)
-- Collect only the nonempty A[i] ∩ X, unless there are none, in which case the result is empty.
-- If any schema is empty, we remove it from the list which constitutes a change.
mSchemas <- forM rss $ \rs -> catchBottom (Just <$> intersectRefSchema loc part rs) (mChange >> pure Nothing)
case catMaybes mSchemas of
[] -> mBottom
schs -> pure schs
oneOf' <- forM (tracedOneOf sch) $ \rss -> do
-- Same as anyOf'. By intersecting we're only making them more disjoint if anything.
mSchemas <- forM rss $ \rs -> catchBottom (Just <$> intersectRefSchema loc part rs) (mChange >> pure Nothing)
case catMaybes mSchemas of
[] -> mBottom
schs -> pure schs
let sch' = (extract sch) {_schemaAllOf = allOf', _schemaAnyOf = anyOf', _schemaOneOf = oneOf'}
-- Now the local changes:
case loc of
PInProperty k loc' -> case IOHM.lookup k $ tracedProperties sch of
Nothing -> error $ "Partitioning via absent property: " <> T.unpack k
Just prop -> do
prop' <- intersectRefSchema loc' part prop
pure $ sch' {_schemaProperties = IOHM.adjust (const prop') k $ _schemaProperties sch'}
PHere -> case part of
CByEnumValue vals -> do
enum' <- case _schemaEnum sch' of
Nothing -> do
mChange
pure $ S.toList vals
Just xs -> do
when (any (`S.notMember` vals) xs) mChange
case filter (`S.member` vals) xs of
[] -> mBottom
xs' -> pure xs'
pure $ sch' {_schemaEnum = Just enum'}
CByProperties {} -> error "CByProperties not implemented"
intersectRefSchema ::
PartitionLocation ->
PartitionChoice ->
Traced (Referenced Schema) ->
IntersectionM (Referenced Schema)
intersectRefSchema loc part rs = do
defs <- R.ask
Inline <$> intersectSchema loc part (dereference defs rs)
intersectCondition :: Traced (Definitions Schema) -> PartitionLocation -> PartitionChoice -> Condition t -> DNF (Condition t)
intersectCondition _defs PHere (CByEnumValue values) cond@(Exactly x) =
if untypeValue x `S.member` values then LiteralDNF cond else bottom
intersectCondition defs (PInProperty k loc) part cond@(Properties props add madd) = case M.lookup k props of
Nothing -> LiteralDNF cond -- shouldn't happen
Just prop -> case runIntersectionM defs $ intersectRefSchema loc part $ propRefSchema prop of
New rs' ->
let trs' = traced (ask (propRefSchema prop) >>> step (Partitioned (loc, part))) rs'
in LiteralDNF $ Properties (M.insert k prop {propRefSchema = trs'} props) add madd
Same _ -> LiteralDNF cond
Disjoint -> bottom
intersectCondition _defs _loc _part cond = LiteralDNF cond
intersectFormula :: Traced (Definitions Schema) -> PartitionLocation -> PartitionChoice -> JsonFormula t -> JsonFormula t
intersectFormula defs loc part = JsonFormula . foldDNF (intersectCondition defs loc part) . getJsonFormula
tryPartition :: ProdCons (Traced (Definitions Schema)) -> ProdCons (JsonFormula t) -> [(Maybe Partition, ProdCons (JsonFormula t))]
tryPartition defs pc = case selectPartition $ partitionJsonFormulas defs pc of
Nothing -> [(Nothing, pc)]
Just (loc, parts) -> [(Just (loc, part), intersectFormula <$> defs <*> pure loc <*> pure part <*> pc) | part <- S.toList parts]
showPartition :: Partition -> Inlines
showPartition = \case
(partition, CByEnumValue (S.toList -> [v])) ->
renderPartitionLocation partition <> " is " <> showJSONValueInline v
(partition, CByEnumValue (S.toList -> vs)) ->
renderPartitionLocation partition <> " has values: "
<> (fold . L.intersperse ", " . fmap showJSONValueInline $ vs)
(partition, CByProperties (S.toList -> incl) (S.toList -> [])) ->
renderPartitionLocation partition <> " contains the properties: " <> listCodes incl
(partition, CByProperties (S.toList -> []) (S.toList -> excl)) ->
renderPartitionLocation partition <> " does not contain the properties: " <> listCodes excl
(partition, CByProperties (S.toList -> incl) (S.toList -> excl)) ->
renderPartitionLocation partition
<> " contains the properties "
<> listCodes incl
<> " and does not contain the properties "
<> listCodes excl
where
listCodes :: [Text] -> Inlines
listCodes = fold . L.intersperse ", " . fmap code
renderPartitionLocation :: PartitionLocation -> Inlines
renderPartitionLocation p = code $ "$" <> renderPartitionLocation' p
where
renderPartitionLocation' :: PartitionLocation -> Text
renderPartitionLocation' PHere = mempty
renderPartitionLocation' (PInProperty prop rest) = "." <> prop <> renderPartitionLocation' rest