やりたいこと
- 入力はcsvファイル
- そのうちの一部の列の値を使って、別のファイル(例:SQL)を出力したい
- csvファイルはレイアウトの変更が有り得るので多少効率が悪くても変更に柔軟に追随できるようにしておきたい
- 仕事で無理やりHaskellを使いたい
方針
- Haskellのcsvライブラリであるcassavaを使う
- cassavaは1行目をヘッダーとして列名で値を取り扱うことができる関数が用意されているので、そちらを使う
- なお、cassavaはデータ型をcsvに変換するエンコードと、csvをデータ型に変換するデコードの両方に対応しているが、今回はデコードに特化した記事とする
- 以降のコードはData.Csvのドキュメントにあるサンプルを元にしている
https://hackage.haskell.org/package/cassava
Data.Csv
前提知識としてData.Csvの型定義のうちデコードに必要なものを見る。
内部のデータ構造としては主にVector
が使われている。Vector
は定数時間でアクセスできる一次元配列とのこと。
ここが理解できなくとも実例が何となく分かれば問題はないので読み飛ばして良いが、分かっていた方が躓いたときに対応しやすい。
import qualified Data.ByteString as S
import qualified Data.HashMap.Strict as HM
import Data.Vector (Vector)
import qualified Data.Vector as V
-- 単一フィールドはByteString
type Field = S.ByteString
-- レコードはCSVファイルの1行。FieldのVector
type Record = Vector Field
-- CSVデータはByteStringのVectorのVector
type Csv = Vector Record
-- ヘッダーは1つ以上の名前があり、名前はその後ろに続くデータのラベルである
-- 名前はByteString。実体はFieldと等価
type Name = S.ByteString
-- ヘッダーはCSVファイルの1行目。全てのCSVファイルにヘッダーがあるわけではない
-- ヘッダーはNameのVector。実体はRecordと等価
type Header = Vector Name
-- HeaderとRecordをzipしてtoListしてhashMap化する
toNamedRecord :: Header -> Record -> NamedRecord
toNamedRecord hdr v = HM.fromList . V.toList $ V.zip hdr v
-- NamedRecordは列名でindex化されたCSVファイルの1行に対応する
-- NameRecordはByteStringの正格なHashMap
type NamedRecord = HM.HashMap S.ByteString S.ByteString
ヘッダーの情報を使って変換する
ヘッダーを参照して、CSVレコードをユーザー定義のデータ型に変換する。
フィールドの名前はヘッダー情報を使って定義する。ヘッダーとはファイルの最初の行のこと。
ヘッダー情報を使った変換はファイル構造の変更 (列の並べ替えや追加など) に対してより堅牢だが多少時間はかかる。
まず、変換に使用するための値を保持する独自型(この例ではPerson
)を定義する。
独自型はFromNamedRecord
のインスタンスにする必要があるが、ここではGHC.Generics
を使って自動導出している(もちろん独自定義も可能。後述の実例では自動導出はしていない)
{-# LANGUAGE DeriveGeneric #-}
import Data.Text (Text)
import GHC.Generics (Generic)
data Person = Person { name :: !Text
, salary :: !Int }
deriving (Generic, Show)
instance FromNamedRecord Person
FromNamedRecord型
メソッドはparseNamedRecord
で、HashMapであるNamedRecord
から独自型のparserを得る
parseNamedRecord :: NamedRecord -> Parser a
decode
decodeByName
:: FromNamedRecord a
-> ByteString -- CSV data
-> Either String (Header, Vector a)
-
ByteString
のCSVレコードを効率的にVector
にデコードする - 入力が不完全または無効なために失敗した場合は
Left msg
が返される -
decodeByName
、decodeByNameWith
(後述)はデータの前にヘッダーがある形式が前提
単一のCSVレコードから変換することができるが、変換に失敗する可能性がある。
たとえばRecordのカラム数が間違っている場合などでは、empty、mzero、またはfailを使用して変換を失敗させる。
Person
型をFromNamedRecord
のインスタンスにしたことでdecodeByName
を使用してcsvデータをデコードできるようになった。
-- 入力csv
-- name,salary
-- John,27
>>> decodeByName "name,salary\r\nJohn,27\r\n" :: Either String (Header, Vector Person)
Right (["name","salary"]
,[Person {name = "John"
, salary = 27}]
)
実例
入力ファイル
例として次のデータを使用する。
Namee dummy Salarye
"John ,Doe" tmp 50000
"Jane ,Doe" tmp 60000
- ファイルはタブ区切りのTSV形式
- 二列目は変換には不要な列である
- 列名は本来使用したいフィールド名(name,salary)と意識的に変えてある
データ保持のための自作型とインスタンスの定義
{-# LANGUAGE OverloadedStrings #-}
import Data.Csv
data Person = Person
{ name :: !String
, salary :: !Int
}
instance FromNamedRecord Person where
parseNamedRecord r = Person <$> r .: "Namee"
<*> r .: "Salarye"
-- (.:) :: FromField a => NamedRecord -> ByteString -> Parser a
-- Alias for lookup
-- 指定したレコードのフィールドを名前で取得
-
ByteString
値を文字列リテラルとして書くためにOverloadedStrings
言語拡張を使用 - 自作型は入力ファイルの形式と一致している必要がない。後続処理に必要な列のみ受けられるようになっていれば良い(dummy列は
Person
型に含めていない) -
parseNamedRecord
は自作型のフィールドラベルではなく、入力csvのヘッダーの値を指定する。 - ヘッダーを指定することで得られた値を自作型の型コンストラクタに引数として引き渡す。
データ変換
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString.Lazy as BL
import Data.Csv
import qualified Data.Vector as V
import Data.Char(ord)
main :: IO ()
main = do
csvData <- BL.readFile "tabTest.tsv"
case decodeByNameWith myOptions csvData of
Left err -> putStrLn err
Right (_, v) -> V.forM_ v $ \ p ->
putStrLn $ mkMsg p
mkMsg :: Person -> String
mkMsg p = name p
<> " earns "
<> show (salary p)
<> " dollars"
myOptions = defaultDecodeOptions {
decDelimiter = fromIntegral (ord '\t')
}
- この例では入力ファイルがtab区切りなので、
decodeByNameWith
関数を使いデリミターをオプションとして指定している(myOptions) -
mkMsg
関数が変換処理の実体。自作型から値を取り出して処理する。
全体
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString.Lazy as BL
import Data.Csv
import qualified Data.Vector as V
import Data.Char(ord)
data Person = Person
{ name :: !String
, salary :: !Int
}
instance FromNamedRecord Person where
parseNamedRecord r = Person <$> r .: "Namee"
<*> r .: "Salarye"
mkMsg :: Person -> String
mkMsg p = name p
<> " earns "
<> show (salary p)
<> " dollars"
main :: IO ()
main = do
csvData <- BL.readFile "tabTest.csv"
case decodeByNameWith myOptions csvData of
Left err -> putStrLn err
Right (_, v) -> V.forM_ v $ \ p ->
putStrLn $ mkMsg p
myOptions = defaultDecodeOptions {
decDelimiter = fromIntegral (ord '\t')
}
> stack runghc csvTest1.hs
John ,Doe earns 50000 dollars
Jane ,Doe earns 60000 dollars