前書き:バリデーション付きCSV読み込み機能は2024年に開発

本記事で取り上げるのは、2024年に開発した nao1215/csv です。本来の予定では、新規に開発した機能の紹介だけ書く予定でした。しかし、本ブログで一度も nao1215/csv の説明をしていなかったようなので、まずは基本機能を説明した後に新機能(DataFrame)について紹介します。

キッカケ:CSV 読み込みが辛い時期があった

君は、CSV を読み込んだことがあるだろうか。
数万行、300列以上のCSVファイルから10個のエラー(書き間違い)を見つけるという作業をした経験は?ない?それは幸せな人生だ。私は何度か経験した。こめかみに青筋が浮いた。

さて、話と口調を戻しましょう。CSV 読み込みは、一筋縄で行かないときがあります。例えば、Excel から CSV エクスポートすると文字コードが UTF-8 ではなかったり、改行が含まれているカラムがあったり、仕様通りに記載されていないカラムが登場したりします。

私が特に辛かったのが、どの行で読み込みエラーが発生したか分からなかったことです。私は、Golang 標準ライブラリの csv パッケージや shogo82148/go-header-csv を利用して CSV を読み込むケースが当時多かったです。しかし、これらのライブラリは、エラーが発生した行番号を教えてくれませんでした(注:go-header-csv は、v0.1.0からエラー発生行番号を教えてくれます)。

また、CSV カラムに関するバリデーション要件が入ってくると、地獄の門が開門します。一度 CSV を読み込んだ後に、カラム単位でバリデーションしたり、「カラム A が◯◯の場合は、カラム B は☓☓でなければならない」などの条件を地道に実装する必要がでてきます。この要件を含むタスクと2度出会いましたが、実装者は苦戦していました。そう、私は実装していません。管理側の立場だったので、レビューしただけでした。このバリデーション処理はカラムが数百単位であったので、愚直に実装すると、かなり見通しの悪いコードが出来上がります。

go-playground/validator を参考に nao1215/csv を開発

自分であれば CSV バリデーションをどのように実装するかを考えた時に、「go-playground/validator のように、 struct tag を使って CSV バリデーション を定義すれば良いのではないか」と発想しました。この発想を形にしたのが、nao1215/csv です。

以下に、サンプルコードを示します。
読み込みたい CSV に対応する構造体(例:person 構造体)を用意し、その構造体フィールドにvalidate:タグおよびバリデーション条件を書くと、CSV 読み込み時にエラーを行番号付きで表示してくれます。実装時(2024年)のマイブームだったのか分かりませんが、i18n(国際化)対応されており、英語、日本語、ロシア語でエラーメッセージを表示できます。何故、i18n 対応したのか。理由が自分でも分かりません。

package csv_test

import (
	"bytes"
	"fmt"

	"github.com/nao1215/csv"
)

func ExampleCSV() {
	input := `id,name,age
1,Gina,23
a,Yulia,25
3,Den1s,30
`
	buf := bytes.NewBufferString(input)
	c, err := csv.NewCSV(buf)
	if err != nil {
		panic(err)
	}

	type person struct {
		ID   int    `validate:"numeric"`
		Name string `validate:"alpha"`
		Age  int    `validate:"gt=24"`
	}
	people := make([]person, 0)

	errs := c.Decode(&people)
	if len(errs) != 0 {
		for _, err := range errs {
			fmt.Println(err.Error())
		}
	}

	// Output:
	// line:2 column age: target is not greater than the threshold value: threshold=24, value=23
	// line:3 column id: target is not a numeric character: value=a
	// line:4 column name: target is not an alphabetic character: value=Den1s
}

func ExampleWithJapaneseLanguage() {
	input := `id,name,age
1,Gina,23
a,Yulia,25
3,Den1s,30
`
	buf := bytes.NewBufferString(input)
	c, err := csv.NewCSV(buf, csv.WithJapaneseLanguage())
	if err != nil {
		panic(err)
	}

	type person struct {
		ID   int    `validate:"numeric"`
		Name string `validate:"alpha"`
		Age  int    `validate:"gt=24"`
	}
	people := make([]person, 0)

	errs := c.Decode(&people)
	if len(errs) != 0 {
		for _, err := range errs {
			fmt.Println(err.Error())
		}
	}

	// Output:
	// line:2 column age: 値がしきい値より大きくありません: threshold=24, value=23
	// line:3 column id: 値が数字ではありません: value=a
	// line:4 column name: 値がアルファベット文字ではありません: value=Den1s
}

対応しているバリデーションタグ一覧

nao1215/csvは、go-playground/validator が対応しているタグを部分的に実装しています。理論的には、go-playground/validator の全タグを導入できます。しかし、nao1215/csv を自分でも使わないので、タグを増やしていません。2025年現在では、優秀な LLM が存在するので、ガッとタグを増やそうと思えば増やせます。

String rules

Tag NameDescription
alphaAlphabetic characters only
alphanumericAlphanumeric characters
asciiASCII characters only
booleanBoolean values
containsContains substring
containsanyContains any of the specified characters
lowercaseLowercase only
numericNumeric only
uppercaseUppercase only

Format rules

Tag NameDescription
emailValid email address

Comparison rules

Tag NameDescription
eqEqual to the specified value
gtGreater than
gteGreater or equal
ltLess than
lteLess or equal
neNot equal

Other rules

Tag NameDescription
lenExact length
maxMaximum value
minMinimum value
oneofMust match one of given values
requiredMust not be empty

本題:v0.3.0で DataFrame 機能を追加

DataFrame 機能は、pandas から着想を得ました。

最近、業務都合で機械学習を勉強しています。機械学習では、Python の pandas を頻繁に使用します。この pandas は、表形式データを加工して、表示する機能を持ちます。ここでの表形式データは DataFrame と呼ばれ、以下のサンプルコードで示すようにデータ操作機能を提供します。

# 読み込み対象の data.csv。この行はないものとして考えてください。
name,age,score
Alice,25,70
Bob,32,88
Clara,41,92
Denis,29,60
import pandas as pd

df = pd.read_csv("data.csv")                      # 読み込み
df = df[df["age"] >= 30]                          # フィルタ
df = df.sort_values("score", ascending=False)     # ソート
df = df.assign(age_group=df["age"] // 10 * 10)    # 新しい列
print(df[["name", "age", "score", "age_group"]])  # 必要な列だけ表示
# 結果
    name  age  score  age_group
2  Clara   41     92         40
1    Bob   32     88         30

上記のような処理を見た時に、「csv に対して、シンタックスシュガーで SQL を実行しているだけでは?」と思いました。私は、CSV に対して SQL を実行できる nao1215/filesql を開発済みだったので、DataFrame を Golang で実現する土台があると気づきました。Golang で pandas もどきを実現するメリットは正直ありませんが、nao1215/filesql を使い倒したかったので(バグ出ししたかったので)、DataFrame を実装することにしました。

以下に、今回機能追加した DataFrame のサンプルコードを示します。

package csv_test

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/nao1215/csv"
)

func ExampleDataFrame_joinFilterSort() {
	users := csv.NewDataFrame(filepath.Join("testdata", "sample.csv")).
		Select("id", "name", "age").
		Mutate("age_bucket", "CASE WHEN age >= 30 THEN '30s' ELSE '20s' END")

	orders := csv.NewDataFrame(filepath.Join("testdata", "orders.csv")).
		Filter("total >= 100").
		Mutate("gross_total", "total + 5")

	depts := csv.NewDataFrame(filepath.Join("testdata", "departments.csv")).
		Select("id", "dept").
		Rename(map[string]string{"dept": "dept_name"})

	df := users.
		Join(orders, "id").
		Join(depts, "id").
		Filter("age >= 23").
		Sort("gross_total", false).
		Select("name", "dept_name", "gross_total", "age_bucket")

	var buf bytes.Buffer
	if err := df.Print(&buf); err != nil {
		panic(err)
	}
	fmt.Print(buf.String())

	// Output:
	// age_bucket  dept_name    gross_total  name
	// 30s         Engineering  155          Denis
	// 20s         Sales        105          Gina
}

誤算:DataFrame 同士を結合する仕様の実現で悩んだ

私は、pandas で一つの DataFrame を使い回す例ばかりを見ていたので、DataFrame 同士が結合できることを知らずに機能追加を始めました。DataFrame 同士を結合できる仕様に気づいた時、私は実装を断念するつもりでした。理由は、素直なやり方で実装できないからです。

具体的に説明しましょう。nao1215/filesql は内部的に SQLite3 を利用しており、初期化時に CSV ファイルパスを受け取ると sql.DB 構造体を返す仕様です。普通に実装すると、DataFrame 1個が sql.DB 構造体1個を持つ構成になります。つまり、DataFrame 同士を結合しようとすると、sql.DB 構造体 A から sql.DB 構造体 B へデータをコピーする処理が発生します。巨大な CSV ファイルを扱う場合、このコピー処理は遅すぎて許容できません。

VTuber の動画を見ながら導いた結論は、「必要なタイミングだけ、データ読み込みや SQL 実行をする」でした。例えば、DataFrame 初期化やフィルタリング設定をしている段階では、SQL を実行する必要がありません。ユーザーが表の中身を確認したくなった時に初めて、SQL を実行すれば十分です。Lazy Load や Deferred initcalls と発想は同じです。必要になるまで、実行を遅らせます。

この発想のおかげで、シンプルな DataFrame を実装できました。当然、pandas と使い勝手は違いますし、機能が足りていません。しかし、私としては nao1215/filesql の新しい使い方を提示できたので満足です。機能拡張は、Issue が飛んできたら行います。

最後に:CSV や SQL 系のツールばかり作っている

あまり CSV や SQL に詳しくないのですが、以下に示すように OSS がそっち系ばかりになってきました。上2つ(sqly, filesql)は、GitHub Star が100個超えたので、実装テーマが良かったのでしょう。なお、sqluv は開発停止する予定です。Text User Interface を調整するのが辛すぎるので……