LoginSignup
6
4

More than 3 years have passed since last update.

HaskellでData.Csv(cassava)を使ってcsvファイルを処理する

Last updated at Posted at 2020-03-12

やりたいこと

  • 入力は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は定数時間でアクセスできる一次元配列とのこと。
ここが理解できなくとも実例が何となく分かれば問題はないので読み飛ばして良いが、分かっていた方が躓いたときに対応しやすい。

Data.Csv.Types.hs
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が返される
  • decodeByNamedecodeByNameWith(後述)はデータの前にヘッダーがある形式が前提

単一の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}]
       )

実例

入力ファイル

例として次のデータを使用する。

salaries.tsv
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のヘッダーの値を指定する。
  • ヘッダーを指定することで得られた値を自作型の型コンストラクタに引数として引き渡す。

データ変換

csvTest1.hs
{-# 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関数が変換処理の実体。自作型から値を取り出して処理する。

全体

全体.hs
{-# 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')
    }
実行結果.hs
> stack runghc csvTest1.hs
John ,Doe earns 50000 dollars
Jane ,Doe earns 60000 dollars
6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4