aeson-schemas-1.3.2: test/TestUtils/Arbitrary.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveLift #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeInType #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module TestUtils.Arbitrary
( ArbitraryObject(..)
, forAllArbitraryObjects
) where
import Control.Monad (forM)
import Data.Aeson (ToJSON(..), Value(..), encode)
import qualified Data.Aeson as Aeson
import qualified Data.HashMap.Strict as HashMap
import Data.List (nub)
import Data.Proxy (Proxy(..))
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Typeable (Typeable)
import GHC.Exts (fromList)
import GHC.TypeLits (KnownSymbol)
import Language.Haskell.TH (ExpQ, listE, runIO)
import Language.Haskell.TH.Instances ()
import Language.Haskell.TH.Quote (QuasiQuoter(quoteType))
import Language.Haskell.TH.Syntax (Lift)
import Test.QuickCheck
import Data.Aeson.Schema (IsSchema, Object, schema)
import Data.Aeson.Schema.Key
(IsSchemaKey(..), SchemaKey, SchemaKey'(..), SchemaKeyV, toContext)
import Data.Aeson.Schema.Type
( Schema'(..)
, SchemaObjectMapV
, SchemaType
, SchemaType'(..)
, SchemaTypeV
, SchemaV
, showSchemaV
, toSchemaObjectV
)
import Data.Aeson.Schema.Utils.All (All(..))
import Data.Aeson.Schema.Utils.NameLike (NameLike(..), fromName)
data ArbitraryObject where
ArbitraryObject
:: IsSchema schema
=> Proxy (Object schema)
-> Value
-> SchemaV
-> ArbitraryObject
-- Show the value and schema as something that could be copied/pasted into GHCi.
instance Show ArbitraryObject where
show (ArbitraryObject _ v schemaV) = unlines
[ "ArbitraryObject:"
, " " ++ show (encode v)
, " [schema| " ++ showSchemaV schemaV ++ " |]"
]
-- | A Template Haskell function to generate a splice for QuickCheck tests to generate arbitrary
-- objects with arbitrary schemas.
--
-- Note that for repeated runs of the test suite, the schemas will be the same, with the actual
-- JSON values generated randomly. You need to recompile in order to generate different schemas.
arbitraryObject :: ExpQ
arbitraryObject = do
arbitrarySchemas <- runIO $ genSchemaTypes 20
[| oneof $(listE $ map mkSchemaGen arbitrarySchemas) |]
where
mkSchemaGen schemaV =
let schemaType = quoteType schema $ showSchemaV schemaV
in [| genSchema' (Proxy :: Proxy (Object $schemaType)) schemaV |]
-- | Splices to a 'forAll' with 'arbitraryObject', outputting information about the object
-- generated, to ensure we get good generation.
--
-- $(forAllArbitraryObjects) :: Testable prop => ArbitraryObject -> prop
forAllArbitraryObjects :: ExpQ
forAllArbitraryObjects = [| forAllArbitraryObjects' $arbitraryObject |]
forAllArbitraryObjects' :: Gen ArbitraryObject -> (ArbitraryObject -> Property) -> Property
forAllArbitraryObjects' genArbitraryObject runTest =
forAll @_ @Property genArbitraryObject $ \o@(ArbitraryObject _ _ schemaType) ->
tabulate' "Key types" (map getKeyType $ getKeys schemaType) $
tabulate' "Schema types" (getSchemaTypes schemaType) $
tabulate' "Object sizes" (map show $ getObjectSizes schemaType) $
tabulate' "Object depth" [show $ getObjectDepth schemaType] $
runTest o
{- Run time helpers -}
deriving instance Lift NameLike
deriving instance Lift SchemaV
deriving instance Lift SchemaTypeV
genSchema' :: forall schema.
( ArbitrarySchema ('SchemaObject schema)
, IsSchema ('Schema schema)
)
=> Proxy (Object ('Schema schema)) -> SchemaV -> Gen ArbitraryObject
genSchema' proxy schemaV = do
v <- genSchema @('SchemaObject schema)
return $ ArbitraryObject proxy v schemaV
getKeyType :: SchemaKeyV -> String
getKeyType = \case
NormalKey _ -> "Normal"
PhantomKey _ -> "Phantom"
getKeys :: SchemaV -> [SchemaKeyV]
getKeys = getKeys' . toSchemaObjectV
where
getKeys' = \case
SchemaMaybe inner -> getKeys' inner
SchemaTry inner -> getKeys' inner
SchemaList inner -> getKeys' inner
SchemaUnion schemas -> concatMap getKeys' schemas
SchemaObject pairs -> concatMap (\(key, inner) -> key : getKeys' inner) pairs
_ -> []
getSchemaTypes :: SchemaV -> [String]
getSchemaTypes = getSchemaTypes' . toSchemaObjectV
where
getSchemaTypes' = \case
SchemaScalar name -> [fromName name]
SchemaMaybe inner -> "SchemaMaybe" : getSchemaTypes' inner
SchemaTry inner -> "SchemaTry" : getSchemaTypes' inner
SchemaList inner -> "SchemaList" : getSchemaTypes' inner
SchemaUnion schemas -> "SchemaUnion" : concatMap getSchemaTypes' schemas
SchemaObject pairs -> "SchemaObject" : concatMap (getSchemaTypes' . snd) pairs
SchemaInclude _ -> error "ArbitraryObject unexpectedly generated a schema that includes another schema"
getObjectSizes :: SchemaV -> [Int]
getObjectSizes = getObjectSizes' . toSchemaObjectV
where
getObjectSizes' = \case
SchemaScalar _ -> []
SchemaMaybe inner -> getObjectSizes' inner
SchemaTry inner -> getObjectSizes' inner
SchemaList inner -> getObjectSizes' inner
SchemaUnion schemas -> concatMap getObjectSizes' schemas
SchemaObject pairs -> length pairs : concatMap (getObjectSizes' . snd) pairs
SchemaInclude _ -> error "ArbitraryObject unexpectedly generated a schema that includes another schema"
getObjectDepth :: SchemaV -> Int
getObjectDepth = getObjectDepth' . toSchemaObjectV
where
getObjectDepth' = \case
SchemaScalar _ -> 0
SchemaMaybe inner -> getObjectDepth' inner
SchemaTry inner -> getObjectDepth' inner
SchemaList inner -> getObjectDepth' inner
SchemaUnion schemas -> maximum $ map getObjectDepth' schemas
SchemaObject pairs -> 1 + maximum (map (getObjectDepth' . snd) pairs)
SchemaInclude _ -> error "ArbitraryObject unexpectedly generated a schema that includes another schema"
tabulate' :: String -> [String] -> Property -> Property
#if MIN_VERSION_QuickCheck(2,12,0)
tabulate' = tabulate
#else
tabulate' _ _ = id
#endif
{- Generating schemas -}
class ArbitrarySchema (schema :: SchemaType) where
genSchema :: Gen Value
instance {-# OVERLAPS #-} ArbitrarySchema ('SchemaScalar Text) where
genSchema = toJSON <$> arbitrary @String
instance (Arbitrary inner, ToJSON inner, Typeable inner) => ArbitrarySchema ('SchemaScalar inner) where
genSchema = toJSON <$> arbitrary @inner
instance ArbitrarySchema inner => ArbitrarySchema ('SchemaMaybe inner) where
genSchema = frequency
[ (3, genSchema @inner)
, (1, pure Null)
]
instance ArbitrarySchema inner => ArbitrarySchema ('SchemaTry inner) where
genSchema = frequency
[ (3, genSchema @inner)
, (1, genValue)
]
where
genValue = oneof
[ pure Null
, Number . realToFrac <$> arbitrary @Double
, Bool <$> arbitrary
, String . Text.pack <$> arbitrary
]
instance ArbitrarySchema inner => ArbitrarySchema ('SchemaList inner) where
genSchema = Array . fromList <$> listOf (genSchema @inner)
instance All ArbitrarySchema schemas => ArbitrarySchema ('SchemaUnion schemas) where
genSchema = oneof $ mapAll @ArbitrarySchema @schemas genSchemaElem
where
genSchemaElem :: forall schema. ArbitrarySchema schema => Proxy schema -> Gen Value
genSchemaElem _ = genSchema @schema
instance All ArbitraryObjectPair pairs => ArbitrarySchema ('SchemaObject (pairs :: [(SchemaKey, SchemaType)])) where
genSchema = Object . HashMap.unions <$> genSchemaPairs
where
genSchemaPairs :: Gen [Aeson.Object]
genSchemaPairs = sequence $ mapAll @ArbitraryObjectPair @pairs genSchemaPair
class IsSchemaKey (Fst pair) => ArbitraryObjectPair (pair :: (SchemaKey, SchemaType)) where
genSchemaPair :: Proxy pair -> Gen Aeson.Object
genSchemaPair _ = toContext schemaKey <$> genInnerSchema @pair
where
schemaKey = toSchemaKeyV $ Proxy @(Fst pair)
genInnerSchema :: Gen Value
instance (IsSchemaKey key, ArbitrarySchema schema) => ArbitraryObjectPair '(key, schema) where
genInnerSchema = genSchema @schema
-- For phantom keys, Maybe is only valid for Objects. Since phantom keys parse the schema with
-- the current object as the context, we should guarantee that this only generates objects, and
-- not Null.
instance {-# OVERLAPS #-} (KnownSymbol key, inner ~ 'SchemaObject a, ArbitrarySchema inner)
=> ArbitraryObjectPair '( 'PhantomKey key, 'SchemaMaybe inner ) where
genInnerSchema = genSchema @inner
-- For phantom keys, Try can be used on any schema, but for all non-object schemas, need to ensure
-- we generate 'Null', because Try on a non-object schema will always be an invalid parse.
instance {-# OVERLAPS #-} (KnownSymbol key, ArbitrarySchema ('SchemaTry inner))
=> ArbitraryObjectPair '( 'PhantomKey key, 'SchemaTry inner ) where
genInnerSchema = castNull <$> genSchema @('SchemaTry inner)
where
castNull inner =
case inner of
Object _ -> inner
_ -> Null
-- For phantom keys, Union can be used on any schemas, as long as at least one is an object schema.
instance {-# OVERLAPS #-} (KnownSymbol key, FilterObjectSchemas schemas ~ objectSchemas, ArbitrarySchema ('SchemaUnion objectSchemas))
=> ArbitraryObjectPair '( 'PhantomKey key, 'SchemaUnion schemas ) where
genInnerSchema = genSchema @('SchemaUnion objectSchemas)
{- Generating schema definitions -}
genSchemaTypes :: Int -> IO [SchemaV]
genSchemaTypes numSchemasToGenerate =
generate $ sequence $ take numSchemasToGenerate
[ resize n arbitrary | n <- [0,2..] ]
instance Arbitrary SchemaV where
arbitrary = Schema <$> sized genSchemaObject
-- | Generate an arbitrary schema.
--
-- SchemaType is a recursive definition, so we want to make sure that generating a schema will
-- terminate, and also not take too long. The ways we account for that are:
-- * Providing an upper bound on the depth of any object schemas in the current object (n / 2)
-- * Providing an upper bound on the number of keys in the current object (n / 3)
-- * Providing an upper bound on the number of schemas in a union (n / 5)
genSchemaObject :: Int -> Gen SchemaObjectMapV
genSchemaObject n = do
keys <- genUniqList1 (n `div` 3) genKey
forM keys $ \key -> frequency
[ (10, genSchemaObjectPairNormal key)
, (1, genSchemaObjectPairPhantom key)
]
where
genSchemaObject' = do
n' <- choose (0, n `div` 2)
SchemaObject <$> genSchemaObject n'
genSchemaObjectPairNormal key = do
schemaType <- frequency $ if n == 0
then scalarSchemaTypes
else allSchemaTypes
return (NormalKey key, schemaType)
genSchemaObjectPairPhantom key = do
schemaType <- frequency
[ (2, SchemaMaybe <$> genSchemaObject')
, (2, SchemaTry <$> frequency nonNullableSchemaTypes)
, (4, genSchemaObject')
, (1, genSchemaUnion genSchemaObject')
]
return (PhantomKey key, schemaType)
scalarSchemaTypes =
[ (4, pure $ SchemaScalar $ NameRef "Bool")
, (4, pure $ SchemaScalar $ NameRef "Int")
, (4, pure $ SchemaScalar $ NameRef "Double")
, (4, pure $ SchemaScalar $ NameRef "Text")
]
nonNullableSchemaTypes =
scalarSchemaTypes ++
[ (2, SchemaList <$> frequency allSchemaTypes)
, (1, genSchemaUnion $ frequency allSchemaTypes)
, (2, genSchemaObject')
]
allSchemaTypes =
nonNullableSchemaTypes ++
[ (2, SchemaMaybe <$> frequency nonNullableSchemaTypes)
, (2, SchemaTry <$> frequency nonNullableSchemaTypes)
]
-- avoid generating big unions by scaling list length
genSchemaUnion gen = SchemaUnion <$> genUniqList1 (n `div` 5) gen
-- | Generate a valid JSON key
-- See Data.Aeson.Schema.TH.Parse.jsonKey'
genKey :: Gen String
genKey = listOf1 $ arbitraryPrintableChar `suchThat` (`notElem` " \"\\!?[](),.@:{}#")
-- | Generate a non-empty and unique list of the given generator.
--
-- Takes in the max size of the list.
genUniqList1 :: Eq a => Int -> Gen a -> Gen [a]
genUniqList1 n gen = do
k <- choose (1, max 1 n)
take k . nub <$> infiniteListOf gen
{- Helper type families -}
type family Fst x where
Fst '(a, _) = a
type family FilterObjectSchemas schemas where
FilterObjectSchemas '[] = '[]
FilterObjectSchemas ('SchemaObject inner ': xs) = 'SchemaObject inner : FilterObjectSchemas xs
FilterObjectSchemas (_ ': xs) = FilterObjectSchemas xs