hq-json-0.1.1: src/HQ/Transformation.hs
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | The transformation DSL over JSON values.
--
-- Transformations are structured as a tree of constructors that describe
-- how to rewrite a JSON value, e.g. @Equal 3 . Add 1@.
module HQ.Transformation where
import Data.Aeson (ToJSON, Value (..))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Text (encodeToLazyText)
import Data.Bool qualified (not)
import Data.List qualified as List
import Data.Scientific (Scientific)
import Data.Text (strip, unpack)
import Data.Text qualified as T
import Data.Text.Lazy (toStrict)
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Show (ShowS, appPrec)
import HQ.Transformation.Error (TransformationError (..))
import Relude hiding (Compose, Const, and, isPrefixOf, length, many, not, or, reverse, some, subtract, toStrict, xor)
import Prelude (Show (showsPrec), showParen, showString)
-- | Transformation expressions over JSON values.
--
-- Transformations read the current JSON value and produce a new one:
-- numeric steps like 'Add' map numbers, string steps like 'ConcatString'
-- map strings, and 'Equal', 'Not' and 'Or' produce booleans. 'Compose'
-- composes two transformations right-to-left: @a . b@ applies @b@ first,
-- then applies @a@ over its result.
data Transformation
= -- | @+ n@: add a number to the current value.
Add Scientific
| -- | @* n@: multiply the current value by a number.
Multiply Scientific
| -- | @- n@: subtract a number from the current value.
Subtract Scientific
| -- | @/ n@: divide the current value by a number.
Divide Scientific
| -- | @< n@: test whether the current number is less than @n@.
Lt Scientific
| -- | @<= n@: test whether the current number is at most @n@.
Lte Scientific
| -- | @++ s@: append a string to a string value.
ConcatString Text
| -- | @concat [..]@: append the given elements to an array value.
ConcatArray [Value]
| -- | @trim@: strip surrounding whitespace from a string value.
Trim
| -- | @replace a b@: replace occurrences of @a@ with @b@ in a string value.
Replace Text Text
| -- | @stripPrefix p@: remove the prefix @p@ from a string value, if present.
StripPrefix Text
| -- | @stripSuffix s@: remove the suffix @s@ from a string value, if present.
StripSuffix Text
| -- | @isPrefixOf p@: test whether a string value starts with @p@.
IsPrefixOf Text
| -- | @isSuffixOf s@: test whether a string value ends with @s@.
IsSuffixOf Text
| -- | @isInfixOf i@: test whether a string value contains @i@.
IsInfixOf Text
| -- | @isEmpty@: test whether an array value is empty.
IsEmpty
| -- | @length@: the length of an array value, as a number.
ArrayLength
| -- | @reverse@: reverse an array value.
ArrayReverse
| -- | @unique@: drop duplicate elements of an array value, keeping first occurrences.
ArrayUnique
| -- | @merge o@: shallow-merge the object operand @o@ into the
-- current object value; @o@ wins member conflicts (like jq @+@).
-- Nested objects are replaced wholesale, not merged.
Merge (KeyMap.KeyMap Value)
| -- | @deepMerge o@: recursively merge the object operand @o@ into
-- the current object value (like jq @*@). Conflicting members
-- that are both objects merge recursively; anything else takes
-- the operand's value.
DeepMerge (KeyMap.KeyMap Value)
| -- | @sort@: sort the elements of an array value in canonical order
-- (null, booleans, numbers, strings, arrays, objects). Stable:
-- equal elements keep their input order.
ArraySort
| -- | @== v@ or @= v@: test the current value for equality with @v@.
Equal Value
| -- | @const v@: replace the current value with @v@, whatever it is.
Const Value
| -- | @not@: negate the current boolean value.
Not
| -- | @a or b@: boolean disjunction of two transformations.
Or Transformation Transformation
| -- | @a and b@: boolean conjunction of two transformations.
And Transformation Transformation
| -- | @a xor b@: boolean exclusive disjunction of two transformations.
Xor Transformation Transformation
| -- | @a . b@: apply @b@, then apply @a@ over its result.
Compose Transformation Transformation
deriving (Eq)
instance Show Transformation where
showsPrec d (Add n) =
showParen (d > appPrec) $ showString "+" . showsJson (Number n)
showsPrec d (Multiply n) =
showParen (d > appPrec) $ showString "*" . showsJson (Number n)
showsPrec d (Subtract n) =
showParen (d > appPrec) $ showString "-" . showsJson (Number n)
showsPrec d (Divide n) =
showParen (d > appPrec) $ showString "/" . showsJson (Number n)
showsPrec d (Lt n) =
showParen (d > appPrec) $ showString "<" . showsJson (Number n)
showsPrec d (Lte n) =
showParen (d > appPrec) $ showString "<=" . showsJson (Number n)
showsPrec d (ConcatString s) =
showParen (d > appPrec) $ showString "++ " . showsPrec (appPrec + 1) s
showsPrec d (ConcatArray els) =
showParen (d > appPrec) $ showString "concat " . showsJson els
showsPrec _ Trim = showString "trim"
showsPrec d (Replace a b) =
showParen (d > appPrec)
$ showString "replace "
. showsPrec (appPrec + 1) a
. showString " "
. showsPrec (appPrec + 1) b
showsPrec d (StripPrefix p) =
showParen (d > appPrec) $ showString "stripPrefix " . showsPrec (appPrec + 1) p
showsPrec d (StripSuffix s) =
showParen (d > appPrec) $ showString "stripSuffix " . showsPrec (appPrec + 1) s
showsPrec d (IsPrefixOf p) =
showParen (d > appPrec) $ showString "isPrefixOf " . showsPrec (appPrec + 1) p
showsPrec d (IsSuffixOf s) =
showParen (d > appPrec) $ showString "isSuffixOf " . showsPrec (appPrec + 1) s
showsPrec d (IsInfixOf i) =
showParen (d > appPrec) $ showString "isInfixOf " . showsPrec (appPrec + 1) i
showsPrec _ IsEmpty = showString "isEmpty"
showsPrec _ ArrayLength = showString "length"
showsPrec _ ArrayReverse = showString "reverse"
showsPrec _ ArrayUnique = showString "unique"
showsPrec d (Merge members) =
showParen (d > appPrec) $ showString "merge " . showsJson (Object members)
showsPrec d (DeepMerge members) =
showParen (d > appPrec) $ showString "deepMerge " . showsJson (Object members)
showsPrec _ ArraySort = showString "sort"
showsPrec d (Equal v) =
showParen (d > appPrec) $ showString "== " . showsJson v
showsPrec d (Const v) =
showParen (d > appPrec) $ showString "const " . showsJson v
showsPrec _ Not = showString "not"
showsPrec d (Or a b) =
showParen (d > orPrec)
$ showsPrec orPrec a
. showString " or "
. showsPrec (orPrec + 1) b
where
orPrec = 4
showsPrec d (And a b) =
showParen (d > andPrec)
$ showsPrec andPrec a
. showString " and "
. showsPrec (andPrec + 1) b
where
andPrec = 6
showsPrec d (Xor a b) =
showParen (d > xorPrec)
$ showsPrec xorPrec a
. showString " xor "
. showsPrec (xorPrec + 1) b
where
xorPrec = 5
showsPrec d (Compose a b) =
showParen (d > composePrec)
$ showsPrec composePrec a
. showString " . "
. showsPrec (composePrec + 1) b
where
composePrec = 7
-- | Render an embedded JSON value in compact DSL syntax, e.g. @3@ or @[1,2]@.
showsJson :: (ToJSON a) => a -> ShowS
showsJson = showString . unpack . toStrict . encodeToLazyText
-- | Apply a transformation to a JSON value.
--
-- Every step is total over 'Value': a step that does not fit the current
-- value (for example adding to a string) fails with a description of the
-- expected value type. Composition applies right-to-left, matching the
-- @a . b@ DSL syntax: @b@ runs first, then @a@ over its result.
runTransformation :: Transformation -> Value -> Either TransformationError Value
runTransformation step value = case step of
Add n -> withNumber (Number . (+ n)) value
Multiply n -> withNumber (Number . (* n)) value
Subtract n -> withNumber (Number . (+ negate n)) value
Divide n -> withNumber (Number . (/ n)) value
Lt n -> withNumber (Bool . (< n)) value
Lte n -> withNumber (Bool . (<= n)) value
ConcatString suffix -> withString (String . (<> suffix)) value
ConcatArray elements -> withArray (Array . (<> fromList elements)) value
Trim -> withString (String . strip) value
Replace needle replacement ->
withString (String . T.replace needle replacement) value
StripPrefix prefix ->
withString (String . stripAffix T.stripPrefix prefix) value
StripSuffix suffix ->
withString (String . stripAffix T.stripSuffix suffix) value
IsPrefixOf prefix -> withString (Bool . T.isPrefixOf prefix) value
IsSuffixOf suffix -> withString (Bool . T.isSuffixOf suffix) value
IsInfixOf infix_ -> withString (Bool . T.isInfixOf infix_) value
IsEmpty -> withArray (Bool . V.null) value
ArrayLength -> withArray (Number . fromIntegral . V.length) value
ArrayReverse -> withArray (Array . V.reverse) value
ArrayUnique -> withArray (Array . fromList . List.nub . toList) value
Merge members -> withObject (Object . KeyMap.union members) value
DeepMerge members -> withObject (\o -> Object (KeyMap.unionWith deepMergeValues o members)) value
ArraySort -> withArray (Array . fromList . List.sortBy compareValues . toList) value
Equal literal -> pure (Bool (value == literal))
Const v -> pure v
Not -> withBool (Bool . Data.Bool.not) value
Or left right -> do
result <- runTransformation left value
case result of
Bool b
| b -> pure result
| otherwise -> runTransformation right value
_ -> Left OrBranchNotBoolean
And left right -> do
result <- runTransformation left value
case result of
Bool b
| b -> runTransformation right value
| otherwise -> pure result
_ -> Left AndBranchNotBoolean
Xor left right -> do
l <- runTransformation left value
r <- runTransformation right value
case (l, r) of
(Bool a, Bool b) -> pure (Bool (a /= b))
_ -> Left XorBranchNotBoolean
Compose left right -> runTransformation right value >>= runTransformation left
where
-- Eliminate one JSON shape: rebuild on match, fail with the
-- shape's error otherwise. Every leaf transformation goes through
-- here, so a new shape adds a projector, not a helper.
with :: (Value -> Maybe a) -> TransformationError -> (a -> Value) -> Value -> Either TransformationError Value
with project err build v = maybe (Left err) (pure . build) (project v)
asNumber :: Value -> Maybe Scientific
asNumber (Number n) = Just n
asNumber _ = Nothing
asString :: Value -> Maybe Text
asString (String s) = Just s
asString _ = Nothing
-- Strip an affix when present, leaving the value unchanged otherwise.
stripAffix :: (Text -> Text -> Maybe Text) -> Text -> Text -> Text
stripAffix stripF affix s = fromMaybe s (stripF affix s)
asArray :: Value -> Maybe (Vector Value)
asArray (Array a) = Just a
asArray _ = Nothing
asObject :: Value -> Maybe (KeyMap.KeyMap Value)
asObject (Object o) = Just o
asObject _ = Nothing
asBool :: Value -> Maybe Bool
asBool (Bool b) = Just b
asBool _ = Nothing
-- Short names, kept as synonyms of 'with'.
withNumber :: (Scientific -> Value) -> Value -> Either TransformationError Value
withNumber = with asNumber ExpectedNumber
withString :: (Text -> Value) -> Value -> Either TransformationError Value
withString = with asString ExpectedString
withArray :: (Vector Value -> Value) -> Value -> Either TransformationError Value
withArray = with asArray ExpectedArray
withObject :: (KeyMap.KeyMap Value -> Value) -> Value -> Either TransformationError Value
withObject = with asObject ExpectedObject
withBool :: (Bool -> Value) -> Value -> Either TransformationError Value
withBool = with asBool ExpectedBoolean
-- | Recursive object merge with right-wins conflicts (like jq @*@):
-- members that are objects on both sides merge recursively, anything
-- else takes the replacement value.
deepMergeValues :: Value -> Value -> Value
deepMergeValues (Object current) (Object replacement) =
Object (KeyMap.unionWith deepMergeValues current replacement)
deepMergeValues _ replacement = replacement
-- | Canonical ordering of JSON values, matching jq's @sort@:
-- null, then booleans, numbers, strings, arrays, objects. Numbers
-- compare numerically, strings lexicographically, arrays
-- element-wise (a proper prefix sorts first), objects by their
-- key/value pairs with keys in order. Total: any two values compare.
compareValues :: Value -> Value -> Ordering
compareValues x y = case compare (valueRank x) (valueRank y) of
EQ -> compareSame x y
other -> other
where
compareSame Null Null = EQ
compareSame (Bool a) (Bool b) = compare a b
compareSame (Number a) (Number b) = compare a b
compareSame (String a) (String b) = compare a b
compareSame (Array a) (Array b) =
lexCompare compareValues (V.toList a) (V.toList b)
compareSame (Object a) (Object b) =
lexCompare comparePair (sorted a) (sorted b)
where
sorted = List.sortOn fst . KeyMap.toList
comparePair (k1, v1) (k2, v2) = compare k1 k2 <> compareValues v1 v2
compareSame a b = compare (valueRank a) (valueRank b)
-- | Lexicographic comparison; a proper prefix sorts first.
lexCompare :: (a -> a -> Ordering) -> [a] -> [a] -> Ordering
lexCompare _ [] [] = EQ
lexCompare _ [] _ = LT
lexCompare _ _ [] = GT
lexCompare c (x : xs) (y : ys) = c x y <> lexCompare c xs ys
-- | Type rank for 'compareValues': jq's cross-type order.
valueRank :: Value -> Int
valueRank Null = 0
valueRank (Bool False) = 1
valueRank (Bool True) = 2
valueRank (Number _) = 3
valueRank (String _) = 4
valueRank (Array _) = 5
valueRank (Object _) = 6