on master help sign in
Add support for detecting duplicate keys (see #142)
commitbe3637354df8185ba56930ed556ae5104f056b9f authorSimon Hengel <sol@typeful.net> dateTue 14 Aug 2018, 10:02 (8 years ago) parent78453de
6 files changed+149−36
ChangeLog.md+4 package.yaml+2 src/Data/Yaml.hs+14−5 src/Data/Yaml/Include.hs+16−4 src/Data/Yaml/Internal.hs+47−27 test/Data/Yaml/IncludeSpec.hs+66
ChangeLog.md+4
@@ -1,5 +1,9 @@
11# ChangeLog for yaml
22
3+## next
4+
5+* Add `decodeFileWithWarnings` which returns warnings for duplicate fields
6+
37## 0.9.0
48
59* Expose style and tags on mappings and sequences in Text.Libyaml [#141](https://github.com/snoyberg/yaml/pull/141)
package.yaml+2
@@ -49,6 +49,7 @@ dependencies:
4949- base >=4.9.1 && <5 # GHC 8.0.2 and later
5050- bytestring >=0.9.1.4
5151- transformers >=0.1
52+- mtl
5253- conduit >=1.2.8 && <1.4
5354- resourcet >=0.3 && <1.3
5455- aeson >=0.11
@@ -133,3 +134,4 @@ tests:
133134 - mockery
134135 - base-compat
135136 - temporary
137+ - raw-strings-qq
src/Data/Yaml.hs+14−5
@@ -32,6 +32,7 @@ module Data.Yaml
3232 -- * Decoding
3333 , decodeEither'
3434 , decodeFileEither
35+ , decodeFileWithWarnings
3536 , decodeThrow
3637 , decodeFileThrow
3738 -- ** More control over decoding
@@ -163,14 +164,14 @@ decode :: FromJSON a
163164 => ByteString
164165 -> Maybe a
165166decode bs = unsafePerformIO
166− $ either (const Nothing) id
167+ $ either (const Nothing) snd
167168 <$> decodeHelper_ (Y.decode bs)
168169{-# DEPRECATED decode "Please use decodeEither or decodeThrow, which provide information on how the decode failed" #-}
169170
170171decodeFile :: FromJSON a
171172 => FilePath
172173 -> IO (Maybe a)
173−decodeFile fp = decodeHelper (Y.decodeFile fp) >>= either throwIO (return . either (const Nothing) id)
174+decodeFile fp = (fmap snd <$> decodeHelper (Y.decodeFile fp)) >>= either throwIO (return . either (const Nothing) id)
174175{-# DEPRECATED decodeFile "Please use decodeFileEither, which does not confused type-directed and runtime exceptions." #-}
175176
176177-- | A version of 'decodeFile' which should not throw runtime exceptions.
@@ -180,12 +181,20 @@ decodeFileEither
180181 :: FromJSON a
181182 => FilePath
182183 -> IO (Either ParseException a)
183−decodeFileEither = decodeHelper_ . Y.decodeFile
184+decodeFileEither = fmap (fmap snd) . decodeFileWithWarnings
185+
186+-- |
187+-- @since 0.10.0
188+decodeFileWithWarnings
189+ :: FromJSON a
190+ => FilePath
191+ -> IO (Either ParseException ([Warning], a))
192+decodeFileWithWarnings = decodeHelper_ . Y.decodeFile
184193
185194decodeEither :: FromJSON a => ByteString -> Either String a
186195decodeEither bs = unsafePerformIO
187196 $ either (Left . prettyPrintParseException) id
188− <$> decodeHelper (Y.decode bs)
197+ <$> (fmap snd <$> decodeHelper (Y.decode bs))
189198{-# DEPRECATED decodeEither "Please use decodeEither' or decodeThrow, which provide more useful failures" #-}
190199
191200-- | More helpful version of 'decodeEither' which returns the 'YamlException'.
@@ -194,7 +203,7 @@ decodeEither bs = unsafePerformIO
194203decodeEither' :: FromJSON a => ByteString -> Either ParseException a
195204decodeEither' = either Left (either (Left . AesonException) Right)
196205 . unsafePerformIO
197− . decodeHelper
206+ . fmap (fmap snd) . decodeHelper
198207 . Y.decode
199208
200209-- | A version of 'decodeEither'' lifted to MonadThrow
src/Data/Yaml/Include.hs+16−4
@@ -1,6 +1,10 @@
11{-# LANGUAGE CPP #-}
22{-# LANGUAGE RankNTypes #-}
3−module Data.Yaml.Include (decodeFile, decodeFileEither) where
3+module Data.Yaml.Include (
4+ decodeFile
5+, decodeFileEither
6+, decodeFileWithWarnings
7+) where
48
59#if !MIN_VERSION_directory(1, 2, 3)
610import Control.Exception (handleJust)
@@ -20,7 +24,7 @@ import Data.Text.Encoding (decodeUtf8)
2024import System.Directory
2125import System.FilePath
2226
23−import Data.Yaml.Internal (ParseException(..), decodeHelper_, decodeHelper)
27+import Data.Yaml.Internal (ParseException(..), Warning(..), decodeHelper_, decodeHelper)
2428import Text.Libyaml hiding (decodeFile)
2529import qualified Text.Libyaml as Y
2630
@@ -65,7 +69,7 @@ decodeFile
6569 :: FromJSON a
6670 => FilePath
6771 -> IO (Maybe a)
68−decodeFile fp = decodeHelper (eventsFromFile fp) >>= either throwIO (return . either (const Nothing) id)
72+decodeFile fp = (fmap snd <$> decodeHelper (eventsFromFile fp)) >>= either throwIO (return . either (const Nothing) id)
6973
7074-- | Like `Data.Yaml.decodeFileEither` but with support for relative and
7175-- absolute includes.
@@ -77,4 +81,12 @@ decodeFileEither
7781 :: FromJSON a
7882 => FilePath
7983 -> IO (Either ParseException a)
80−decodeFileEither = decodeHelper_ . eventsFromFile
84+decodeFileEither = fmap (fmap snd) . decodeFileWithWarnings
85+
86+-- |
87+-- @since 0.10.0
88+decodeFileWithWarnings
89+ :: FromJSON a
90+ => FilePath
91+ -> IO (Either ParseException ([Warning], a))
92+decodeFileWithWarnings = decodeHelper_ . eventsFromFile
src/Data/Yaml/Internal.hs+47−27
@@ -6,6 +6,7 @@ module Data.Yaml.Internal
66 (
77 ParseException(..)
88 , prettyPrintParseException
9+ , Warning(..)
910 , parse
1011 , decodeHelper
1112 , decodeHelper_
@@ -19,23 +20,27 @@ import Control.Applicative ((<$>), Applicative(..))
1920#endif
2021import Control.Applicative ((<|>))
2122import Control.Exception
22−import Control.Monad (liftM, ap, unless)
23+import Control.Monad (liftM, ap, when, unless)
2324import Control.Monad.IO.Class (MonadIO, liftIO)
2425import Control.Monad.Trans.Class (MonadTrans, lift)
2526import Control.Monad.Trans.Resource (ResourceT, runResourceT)
26−import Control.Monad.Trans.State
27+import Control.Monad.RWS
2728import Data.Aeson
29+import Data.Aeson.Internal (JSONPath, JSONPathElement(..))
2830import Data.Aeson.Types hiding (parse)
2931import qualified Data.Attoparsec.Text as Atto
3032import Data.Bits (shiftL, (.|.))
3133import Data.ByteString (ByteString)
3234import Data.Char (toUpper, ord)
35+import Data.List
3336import Data.Conduit ((.|), ConduitM, runConduit)
3437import qualified Data.Conduit.List as CL
3538import qualified Data.HashMap.Strict as M
3639import qualified Data.HashSet as HashSet
3740import Data.Map (Map)
3841import qualified Data.Map as Map
42+import Data.Set (Set)
43+import qualified Data.Set as Set
3944import Data.Scientific (Scientific)
4045import Data.Text (Text, pack)
4146import qualified Data.Text as T
@@ -125,15 +130,21 @@ instance Monad m => Monad (PErrorT m) where
125130instance MonadTrans PErrorT where
126131 lift = PErrorT . liftM Right
127132instance MonadIO m => MonadIO (PErrorT m) where
128− liftIO = lift . liftIO
133+ liftIO = liftIO
129134
130135defineAnchor :: Value -> String -> ConduitM e o Parse ()
131−defineAnchor value name = lift $ modify $ Map.insert name value
136+defineAnchor value name = modify $ Map.insert name value
132137
133138lookupAnchor :: String -> ConduitM e o Parse (Maybe Value)
134−lookupAnchor name = lift $ gets (Map.lookup name)
139+lookupAnchor name = gets (Map.lookup name)
135140
136−type Parse = StateT (Map String Value) (ResourceT IO)
141+data Warning = DuplicateKey JSONPath
142+ deriving (Eq, Show)
143+
144+addWarning :: Warning -> ConduitM e o Parse ()
145+addWarning = tell . return
146+
147+type Parse = RWST JSONPath [Warning] (Map String Value) (ResourceT IO)
137148
138149requireEvent :: Event -> ConduitM Event o Parse ()
139150requireEvent e = do
@@ -199,8 +210,8 @@ parseO = do
199210 me <- CL.head
200211 case me of
201212 Just (EventScalar v tag style a) -> textToValue style tag <$> parseScalar v a style tag
202− Just (EventSequenceStart _ _ a) -> parseS a id
203− Just (EventMappingStart _ _ a) -> parseM a M.empty
213+ Just (EventSequenceStart _ _ a) -> parseS 0 a id
214+ Just (EventMappingStart _ _ a) -> parseM mempty a M.empty
204215 Just (EventAlias an) -> do
205216 m <- lookupAnchor an
206217 case m of
@@ -208,10 +219,11 @@ parseO = do
208219 Just v -> return v
209220 _ -> liftIO $ throwIO $ UnexpectedEvent me Nothing
210221
211−parseS :: Y.Anchor
222+parseS :: Int
223+ -> Y.Anchor
212224 -> ([Value] -> [Value])
213225 -> ConduitM Event o Parse Value
214−parseS a front = do
226+parseS n a front = do
215227 me <- CL.peek
216228 case me of
217229 Just EventSequenceEnd -> do
@@ -220,13 +232,14 @@ parseS a front = do
220232 mapM_ (defineAnchor res) a
221233 return res
222234 _ -> do
223− o <- parseO
224− parseS a $ front . (:) o
235+ o <- local (Index n :) parseO
236+ parseS (succ n) a $ front . (:) o
225237
226−parseM :: Y.Anchor
238+parseM :: Set Text
239+ -> Y.Anchor
227240 -> M.HashMap Text Value
228241 -> ConduitM Event o Parse Value
229−parseM a front = do
242+parseM mergedKeys a front = do
230243 me <- CL.head
231244 case me of
232245 Just EventMappingEnd -> do
@@ -243,48 +256,55 @@ parseM a front = do
243256 Just (String t) -> return t
244257 Just v -> liftIO $ throwIO $ NonStringKeyAlias an v
245258 _ -> liftIO $ throwIO $ UnexpectedEvent me Nothing
246− o <- parseO
247259
248− let al = M.insert s o front
249− al' = if s == pack "<<"
260+ (mergedKeys', al') <- local (Key s :) $ do
261+ o <- parseO
262+ let al = do
263+ when (M.member s front && Set.notMember s mergedKeys) $ do
264+ path <- reverse <$> ask
265+ addWarning (DuplicateKey path)
266+ return (Set.delete s mergedKeys, M.insert s o front)
267+ if s == pack "<<"
250268 then case o of
251− Object l -> M.union front l
252− Array l -> M.union front $ foldl mergeObjects M.empty $ V.toList l
269+ Object l -> return (merge l)
270+ Array l -> return $ merge $ foldl mergeObjects M.empty $ V.toList l
253271 _ -> al
254272 else al
255− parseM a al'
273+ parseM mergedKeys' a al'
256274 where mergeObjects al (Object om) = M.union al om
257275 mergeObjects al _ = al
258276
277+ merge xs = (Set.fromList (M.keys xs \\ M.keys front), M.union front xs)
278+
259279decodeHelper :: FromJSON a
260280 => ConduitM () Y.Event Parse ()
261− -> IO (Either ParseException (Either String a))
281+ -> IO (Either ParseException ([Warning], Either String a))
262282decodeHelper src = do
263283 -- This used to be tryAny, but the fact is that catching async
264284 -- exceptions is fine here. We'll rethrow them immediately in the
265285 -- otherwise clause.
266− x <- try $ runResourceT $ flip evalStateT Map.empty $ runConduit $ src .| parse
286+ x <- try $ runResourceT $ evalRWST (runConduit $ src .| parse) [] Map.empty
267287 case x of
268288 Left e
269289 | Just pe <- fromException e -> return $ Left pe
270290 | Just ye <- fromException e -> return $ Left $ InvalidYaml $ Just (ye :: YamlException)
271291 | otherwise -> throwIO e
272− Right y -> return $ Right $ parseEither parseJSON y
292+ Right (y, warnings) -> return $ Right (warnings, parseEither parseJSON y)
273293
274294decodeHelper_ :: FromJSON a
275295 => ConduitM () Event Parse ()
276− -> IO (Either ParseException a)
296+ -> IO (Either ParseException ([Warning], a))
277297decodeHelper_ src = do
278− x <- try $ runResourceT $ flip evalStateT Map.empty $ runConduit $ src .| parse
298+ x <- try $ runResourceT $ evalRWST (runConduit $ src .| parse) [] Map.empty
279299 return $ case x of
280300 Left e
281301 | Just pe <- fromException e -> Left pe
282302 | Just ye <- fromException e -> Left $ InvalidYaml $ Just (ye :: YamlException)
283303 | otherwise -> Left $ OtherParseException e
284− Right y -> either
304+ Right (y, warnings) -> either
285305 (Left . AesonException)
286306 Right
287− (parseEither parseJSON y)
307+ ((,) warnings <$> parseEither parseJSON y)
288308
289309-- | Strings which must be escaped so as not to be treated as non-string scalars.
290310specialStrings :: HashSet.HashSet Text
test/Data/Yaml/IncludeSpec.hs+66
@@ -1,12 +1,18 @@
1+{-# LANGUAGE QuasiQuotes #-}
12{-# LANGUAGE OverloadedStrings #-}
23module Data.Yaml.IncludeSpec (main, spec) where
34
45import Test.Hspec
56import Data.List (isPrefixOf)
67import Data.Aeson
8+import Data.Aeson.Internal (JSONPathElement(..))
79import Data.Yaml (ParseException(InvalidYaml))
810import Data.Yaml.Include
11+import Data.Yaml.Internal
912import Text.Libyaml (YamlException(YamlException))
13+import Test.Mockery.Directory
14+import Text.RawString.QQ
15+import Data.Yaml.TH (yamlQQ)
1016
1117main :: IO ()
1218main = hspec spec
@@ -52,6 +58,66 @@ spec = do
5258 (decodeFileEither "./does_not_exist.yaml" :: IO (Either ParseException Value)) >>=
5359 (`shouldSatisfy` either isYamlFileNotFoundException (const False))
5460
61+ describe "decodeFileWithWarnings" $ around_ inTempDirectory $ do
62+ it "warns on duplicate keys" $ do
63+ writeFile "foo.yaml" [r|
64+ foo: 23
65+ foo: bar
66+ |]
67+ Right result <- decodeFileWithWarnings "foo.yaml"
68+ result `shouldBe` ([DuplicateKey [Key "foo"]], [yamlQQ|
69+ foo: bar
70+ |])
71+
72+ it "warns on nested duplicate keys" $ do
73+ writeFile "foo.yaml" [r|
74+ foo:
75+ - 42
76+ - bar: 23
77+ bar: baz
78+ |]
79+ Right result <- decodeFileWithWarnings "foo.yaml"
80+ result `shouldBe` ([DuplicateKey [Key "foo", Index 1, Key "bar"]], [yamlQQ|
81+ foo:
82+ - 42
83+ - bar: baz
84+ |])
85+
86+ context "when overriding a merged key" $ do
87+ it "does not warn" $ do
88+ writeFile "foo.yaml" [r|
89+ foo-1: &my-ref
90+ bar: 23
91+ foo-2:
92+ <<: *my-ref
93+ bar: 42
94+ |]
95+ Right result <- decodeFileWithWarnings "foo.yaml"
96+ result `shouldBe` ([], [yamlQQ|
97+ foo-1:
98+ bar: 23
99+ foo-2:
100+ bar: 42
101+ |])
102+
103+ context "when overriding twice" $ do
104+ it "warns" $ do
105+ writeFile "foo.yaml" [r|
106+ foo-1: &my-ref
107+ bar: 23
108+ foo-2:
109+ <<: *my-ref
110+ bar: 42
111+ bar: 65
112+ |]
113+ Right result <- decodeFileWithWarnings "foo.yaml"
114+ result `shouldBe` ([DuplicateKey [Key "foo-2", Key "bar"]], [yamlQQ|
115+ foo-1:
116+ bar: 23
117+ foo-2:
118+ bar: 65
119+ |])
120+
55121isYamlFileNotFoundException :: ParseException -> Bool
56122isYamlFileNotFoundException (InvalidYaml (Just (YamlException msg)))
57123 | "Yaml file not found: " `isPrefixOf` msg = True