hydra-0.15.0: src/main/haskell/Hydra/Tools/Csv.hs
-- | A collection of tools for working with comma-separated values (CSVs).
module Hydra.Tools.Csv where
import Hydra.Kernel
import Hydra.Tabular
import qualified Hydra.Relational as RM
import Hydra.Tools.Tabular
import qualified Control.Monad as CM
import qualified Data.List as L
import qualified Data.Map as M
import qualified Data.Maybe as Y
-- Untyped tables --------------------------------------------------------------
-- | Encodes a single DataRow as a line of a CSV, and vice versa.
-- No support for optional cells; only empty strings. No attempt is made to check that rows are of a consistent width.
dataRowCsvCoder :: Coder (DataRow String) String
dataRowCsvCoder = Coder encode decode
where
encode _cx (DataRow cells) = Right $ encodeCsvLine $ Y.catMaybes cells
decode _cx line = Right $ DataRow $ fmap Just $ decodeCsvLine line
tableCsvCoder :: Bool -> Coder (Table String) [String]
tableCsvCoder hasHeader = Coder encode decode
where
encode cx (Table mheader rows) = do
hrows <- headerRows
drows <- CM.mapM (coderEncode dataRowCsvCoder cx) rows
return $ hrows ++ drows
where
headerRows = if hasHeader
then case mheader of
Just (HeaderRow names) -> Right [encodeCsvLine names]
Nothing -> Left $ ErrorOther (OtherError "missing header")
else Right []
decode cx rows = do
(mheader, rest) <- if hasHeader
then if L.null rows
then Left $ ErrorOther (OtherError "missing header")
else Right (Just $ HeaderRow $ decodeCsvLine $ head rows, tail rows)
else Right (Nothing, rows)
drows <- CM.mapM (coderDecode dataRowCsvCoder cx) rest
return $ Table mheader drows
-- Relational tables -----------------------------------------------------------
type DomainCoders t v = M.Map t (Coder v String)
relationCsvCoder :: (Ord t, Show t) => DomainCoders t v -> RM.RelationSchema t -> Bool -> Context -> Either Error (Coder (RM.Relation v) [String])
relationCsvCoder coderMap schema hasHeader cx = do
coder <- rowCsvCoder coderMap schema cx
return $ Coder (encode coder) (decode coder)
where
encode coder cx (RM.Relation rows) = do
dataRows <- CM.mapM (coderEncode coder cx) rows
return $ headerRows ++ dataRows
where
headerRows = if hasHeader
then [encodeCsvLine (RM.unColumnName . RM.columnSchemaName <$> (RM.relationSchemaColumns schema))]
else []
decode coder cx inRows = do
outRows <- CM.mapM (coderDecode coder cx) dataRows
return $ RM.Relation outRows
where
dataRows = if hasHeader then L.tail inRows else inRows
-- | Encodes a single Row as a line of a CSV, and vice versa.
rowCsvCoder :: (Ord t, Show t) => DomainCoders t v -> RM.RelationSchema t -> Context -> Either Error (Coder (RM.Row v) String)
rowCsvCoder coderMap schema cx = do
coders <- CM.mapM findCoder (RM.columnSchemaDomain <$> RM.relationSchemaColumns schema)
return $ Coder (encode coders) (decode coders)
where
encode coders cx' (RM.Row cells) = encodeCsvLine <$> (encodeCells cx' coders cells)
decode coders cx' line = RM.Row <$> (decodeCells cx' coders $ decodeCsvLine line)
findCoder typ = case M.lookup typ coderMap of
Nothing -> Left $ ErrorOther (OtherError $ "no coder for type: " ++ show typ)
Just c -> pure c
encodeCells cx' coders = CM.zipWithM (\c v -> coderEncode c cx' v) coders
decodeCells cx' coders = CM.zipWithM (\c v -> coderDecode c cx' v) coders
stringCoders :: DomainCoders () String
stringCoders = M.singleton () $ Coder encode decode
where
encode _cx s = Right s
decode _cx s = Right s
stringSchema :: Int -> RM.RelationSchema ()
stringSchema len = RM.RelationSchema (RM.RelationName $ "StringTable" ++ show len) (colSchema <$> [1..len]) [] []
where
colSchema i = RM.ColumnSchema (RM.ColumnName $ "Column" ++ show i) ()
-- Common functions ------------------------------------------------------------
decodeCsvCell :: String -> String
decodeCsvCell ('"':xs) = decodeCsvQuotes (init xs) -- Remove surrounding quotes
decodeCsvCell cell = cell -- If not quoted, return as is
decodeCsvLine :: String -> [String]
decodeCsvLine xs = fmap decodeCsvCell (splitCsvLine xs)
decodeCsvQuotes :: String -> String
decodeCsvQuotes [] = []
decodeCsvQuotes ('"':'"':ys) = '"' : decodeCsvQuotes ys -- Handle escaped quotes
decodeCsvQuotes (y:ys) = y : decodeCsvQuotes ys
encodeCsvCell :: String -> String
encodeCsvCell cell
| any (`elem` cell) [',', '\n', '"'] = '"' : escapeCsvQuotes cell ++ "\""
| otherwise = cell
encodeCsvLine :: [String] -> String
encodeCsvLine = L.intercalate "," . fmap encodeCsvCell
escapeCsvQuotes :: String -> String
escapeCsvQuotes [] = []
escapeCsvQuotes (x:xs)
| x == '"' = '"' : '"' : escapeCsvQuotes xs
| otherwise = x : escapeCsvQuotes xs
splitCsvLine :: String -> [String]
splitCsvLine [] = []
splitCsvLine ('"':xs) = ('"':quoted ++ "\"") : splitCsvLine next
where
(quoted, rest) = span (/= '"') xs
afterQuote = drop 1 rest -- Skip closing quote
next = dropWhile (== ',') afterQuote -- Skip comma
splitCsvLine xs = cell : splitCsvLine (drop 1 rest) -- Skip comma
where
(cell, rest) = break (== ',') xs