hledger/hledger-lib/Hledger/Data/Json.hs
Stephen Morgan 4cfd3cb590 lib!: Remove GenericSourcePos, and replace it with either SourcePos or
(SourcePos, SourcePos).

This has been marked for possible removal for a while. We are keeping
strictly more information. Possible edge cases arise with Timeclock and
CsvReader, but I think these are covered.

The particular motivation for getting rid of this is that
GenericSourcePos is creating some awkward import considerations for
little gain. Removing this enables some flattening of the module
dependency tree.
2021-09-20 08:38:33 -10:00

274 lines
10 KiB
Haskell
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{-
JSON instances. Should they be in Types.hs ?
-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Hledger.Data.Json (
-- * Instances
-- * Utilities
toJsonText
,writeJsonFile
,readJsonFile
) where
import Data.Aeson
import Data.Aeson.Encode.Pretty (Config(..), Indent(..), NumberFormat(..),
encodePretty', encodePrettyToTextBuilder')
--import Data.Aeson.TH
import qualified Data.ByteString.Lazy as BL
import Data.Decimal (DecimalRaw(..), roundTo)
import Data.Maybe (fromMaybe)
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB
import Text.Megaparsec (Pos, SourcePos, mkPos, unPos)
import Hledger.Data.Types
import Hledger.Data.Amount (amountsRaw, mixed)
-- To JSON
instance ToJSON Status
instance ToJSON SourcePos
-- Use the same encoding as the underlying Int
instance ToJSON Pos where
toJSON = toJSON . unPos
toEncoding = toEncoding . unPos
-- https://github.com/simonmichael/hledger/issues/1195
-- The default JSON output for Decimal can contain 255-digit integers
-- (for repeating decimals caused by implicit transaction prices).
-- JSON output is intended to be consumed by diverse apps and
-- programming languages, which can't handle numbers like that.
-- From #1195:
--
-- > - JavaScript uses 64-bit IEEE754 numbers which can only accurately
-- > represent integers up to 9007199254740991(i.e. a maximum of 15 digits).
-- > - Javas largest integers are limited to18digits.
-- > - Python3 integers are unbounded.
-- > - Python2 integers are limited to18digits like Java.
-- > - C and C++ number limits depend on platformmost platforms should
-- > be able to represent unsigned integers up to 64bits, i.e. 19digits.
--
-- What is the best compromise for both accuracy and practicality ?
-- For now, we provide both the maximum precision representation
-- (decimalPlaces & decimalMantissa), and a floating point representation
-- with up to 10 decimal places (and an unbounded number of integer digits).
-- We hope the mere presence of the large number in JSON won't break things,
-- and that the overall number of significant digits in the floating point
-- remains manageable in practice. (I'm not sure how to limit the number
-- of significant digits in a Decimal right now.)
instance (Integral a, ToJSON a) => ToJSON (DecimalRaw a) where
toJSON = object . decimalKV
toEncoding = pairs . mconcat . decimalKV
decimalKV :: (KeyValue kv, Integral a, ToJSON a) => DecimalRaw a -> [kv]
decimalKV d = let d' = if decimalPlaces d <= 10 then d else roundTo 10 d in
[ "decimalPlaces" .= decimalPlaces d'
, "decimalMantissa" .= decimalMantissa d'
, "floatingPoint" .= (realToFrac d' :: Double)
]
instance ToJSON Amount
instance ToJSON AmountStyle
-- Use the same JSON serialisation as Maybe Word8
instance ToJSON AmountPrecision where
toJSON = toJSON . \case
Precision n -> Just n
NaturalPrecision -> Nothing
toEncoding = toEncoding . \case
Precision n -> Just n
NaturalPrecision -> Nothing
instance ToJSON Side
instance ToJSON DigitGroupStyle
instance ToJSON MixedAmount where
toJSON = toJSON . amountsRaw
toEncoding = toEncoding . amountsRaw
instance ToJSON BalanceAssertion
instance ToJSON AmountPrice
instance ToJSON MarketPrice
instance ToJSON PostingType
instance ToJSON Posting where
toJSON = object . postingKV
toEncoding = pairs . mconcat . postingKV
postingKV :: KeyValue kv => Posting -> [kv]
postingKV Posting{..} =
[ "pdate" .= pdate
, "pdate2" .= pdate2
, "pstatus" .= pstatus
, "paccount" .= paccount
, "pamount" .= pamount
, "pcomment" .= pcomment
, "ptype" .= ptype
, "ptags" .= ptags
, "pbalanceassertion" .= pbalanceassertion
-- To avoid a cycle, show just the parent transaction's index number
-- in a dummy field. When re-parsed, there will be no parent.
, "ptransaction_" .= maybe "" (show.tindex) ptransaction
-- This is probably not wanted in json, we discard it.
, "poriginal" .= (Nothing :: Maybe Posting)
]
instance ToJSON Transaction
instance ToJSON TransactionModifier
instance ToJSON TMPostingRule
instance ToJSON PeriodicTransaction
instance ToJSON PriceDirective
instance ToJSON DateSpan
instance ToJSON Interval
instance ToJSON Period
instance ToJSON AccountAlias
instance ToJSON AccountType
instance ToJSONKey AccountType
instance ToJSON AccountDeclarationInfo
instance ToJSON PayeeDeclarationInfo
instance ToJSON Commodity
instance ToJSON TimeclockCode
instance ToJSON TimeclockEntry
instance ToJSON Journal
instance ToJSON Account where
toJSON = object . accountKV
toEncoding = pairs . mconcat . accountKV
accountKV :: KeyValue kv => Account -> [kv]
accountKV a =
[ "aname" .= aname a
, "aebalance" .= aebalance a
, "aibalance" .= aibalance a
, "anumpostings" .= anumpostings a
, "aboring" .= aboring a
-- To avoid a cycle, show just the parent account's name
-- in a dummy field. When re-parsed, there will be no parent.
, "aparent_" .= maybe "" aname (aparent a)
-- Just the names of subaccounts, as a dummy field, ignored when parsed.
, "asubs_" .= map aname (asubs a)
-- The actual subaccounts (and their subs..), making a (probably highly redundant) tree
-- ,"asubs" .= asubs a
-- Omit the actual subaccounts
, "asubs" .= ([]::[Account])
]
instance ToJSON Ledger
-- From JSON
instance FromJSON Status
instance FromJSON SourcePos
-- Use the same encoding as the underlying Int
instance FromJSON Pos where
parseJSON = fmap mkPos . parseJSON
instance FromJSON Amount
instance FromJSON AmountStyle
-- Use the same JSON serialisation as Maybe Word8
instance FromJSON AmountPrecision where
parseJSON = fmap (maybe NaturalPrecision Precision) . parseJSON
instance FromJSON Side
instance FromJSON DigitGroupStyle
instance FromJSON MixedAmount where
parseJSON = fmap (mixed :: [Amount] -> MixedAmount) . parseJSON
instance FromJSON BalanceAssertion
instance FromJSON AmountPrice
instance FromJSON MarketPrice
instance FromJSON PostingType
instance FromJSON Posting
instance FromJSON Transaction
instance FromJSON AccountDeclarationInfo
-- XXX The ToJSON instance replaces subaccounts with just names.
-- Here we should try to make use of those to reconstruct the
-- parent-child relationships.
instance FromJSON Account
-- Decimal, various attempts
--
-- https://stackoverflow.com/questions/40331851/haskell-data-decimal-as-aeson-type
----instance FromJSON Decimal where parseJSON =
---- A.withScientific "Decimal" (return . right . eitherFromRational . toRational)
--
-- https://github.com/bos/aeson/issues/474
-- http://hackage.haskell.org/package/aeson-1.4.2.0/docs/Data-Aeson-TH.html
-- $(deriveFromJSON defaultOptions ''Decimal) -- doesn't work
-- $(deriveFromJSON defaultOptions ''DecimalRaw) -- works; requires TH, but gives better parse error messages
--
-- https://github.com/PaulJohnson/Haskell-Decimal/issues/6
instance FromJSON (DecimalRaw Integer)
--
-- @simonmichael, I think the code in your first comment should work if it compiles—though “work” doesnt mean you can parse a JSON number directly into a `Decimal` using the generic instance, as youve discovered.
--
--Error messages with these extensions are always rather cryptic, but Id prefer them to Template Haskell. Typically youll want to start by getting a generic `ToJSON` instance working, then use that to figure out what the `FromJSON` instance expects to parse: for a correct instance, `encode` and `decode` should give you an isomorphism between your type and a subset of `Bytestring` (up to the `Maybe` wrapper that `decode` returns).
--
--I dont have time to test it right now, but I think it will also work without `DeriveAnyClass`, just using `DeriveGeneric` and `StandAloneDeriving`. It should also work to use the [`genericParseJSON`](http://hackage.haskell.org/package/aeson/docs/Data-Aeson.html#v:genericParseJSON) function to implement the class explicitly, something like this:
--
--{-# LANGUAGE DeriveGeneric #-}
--{-# LANGUAGE StandAloneDeriving #-}
--import GHC.Generics
--import Data.Aeson
--deriving instance Generic Decimal
--instance FromJSON Decimal where
-- parseJSON = genericParseJSON defaultOptions
--
--And of course you can avoid `StandAloneDeriving` entirely if youre willing to wrap `Decimal` in your own `newtype`.
-- XXX these will allow reading a Journal, but currently the
-- jdeclaredaccounttypes Map gets serialised as a JSON list, which
-- can't be read back.
--
-- instance FromJSON AccountAlias
-- instance FromJSONKey AccountType where fromJSONKey = genericFromJSONKey defaultJSONKeyOptions
-- instance FromJSON AccountType
-- instance FromJSON ClockTime
-- instance FromJSON Commodity
-- instance FromJSON DateSpan
-- instance FromJSON Interval
-- instance FromJSON Period
-- instance FromJSON PeriodicTransaction
-- instance FromJSON PriceDirective
-- instance FromJSON TimeclockCode
-- instance FromJSON TimeclockEntry
-- instance FromJSON TransactionModifier
-- instance FromJSON Journal
-- Utilities
-- | Config for pretty printing JSON output.
jsonConf :: Config
jsonConf = Config{confIndent=Spaces 2, confCompare=compare, confNumFormat=Generic, confTrailingNewline=True}
-- | Show a JSON-convertible haskell value as pretty-printed JSON text.
toJsonText :: ToJSON a => a -> TL.Text
toJsonText = TB.toLazyText . encodePrettyToTextBuilder' jsonConf
-- | Write a JSON-convertible haskell value to a pretty-printed JSON file.
-- Eg: writeJsonFile "a.json" nulltransaction
writeJsonFile :: ToJSON a => FilePath -> a -> IO ()
writeJsonFile f = BL.writeFile f . encodePretty' jsonConf
-- | Read a JSON file and decode it to the target type, or raise an error if we can't.
-- Eg: readJsonFile "a.json" :: IO Transaction
readJsonFile :: FromJSON a => FilePath -> IO a
readJsonFile f = do
bl <- BL.readFile f
-- PARTIAL:
let v = fromMaybe (error $ "could not decode JSON in "++show f++" to target value")
(decode bl :: Maybe Value)
case fromJSON v :: FromJSON a => Result a of
Error e -> error e
Success t -> return t