packages feed

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