📬

【Go/PostgreSQL】selectしてany型で受け取りたい!

2024/12/05に公開

DBからのデータの受け取り方

Go言語で database/sql github.com/lib/pq パッケージを使用してPostgreSQLからテーブルをselectするとき、通常はテーブルのスキーマに合わせた構造体型を定義しておくことになります。
しかし、ユーザから入力された任意のクエリを実行させたい場合など、予め取得するテーブルのスキーマが決まっていない場合は、構造体型の代わりに[]any型を使用することになります。

    // DB接続
    db, err := sql.Open("postgres", "host=localhost, port=5432 ...")
    if err != nil {
        return nil, err
    }
    defer db.Close()

    // クエリ実行
    cursor, err := db.Query("select ...")
    if err != nil {
        return nil, err
    }
    defer cursor.Close()

    // 値を取得
    rows := make([][]any, 0)
    for cursor.Next() {
        // 1レコード分のデータを []any 型で受け取る
        row := make([]any, len(cols))
        prow := make([]any, len(cols))
        for i := range row {
            prow[i] = &row[i]
        }
        err = cursor.Scan(prow...)
        if err != nil {
            return nil, err
        }
        rows = append(rows, row)
    }
    if err := cursor.Err(); err != nil {
        return nil, err
    }

PostgreSQLからGoへの型変換の行われ方

各カラムの値はany型の変数に格納されることになるのですが、データベースとGoで扱える型が異なるため、その対応関係を知っておく必要があります。

また、多くの場合、自分で扱いやすい型への変換処理を用意することになると思います。変換処理なんて用意しなくても、それなりにうまくGoの型が設定されるのでは?と思えるのですが、そうは問屋が卸しません。例えば、PostgreSQLのnumeric(10,4)型はGoでは[]byte型で受け取ることになります。数値型なのになぜスライス型になるのかというと、PostgreSQLの配列リテラルの表記法の文字列としてデータを受け取ることになるためです。具体的には、1.234[]byte("1.2340") という文字列をバイトコードで表現した値になります。データベースが扱う可変長のデータをGoで受け取るためには仕方ないことだと思いますが、このままでは扱いづらくて仕方がないため、やはり変換処理が必要になってきます。

PostgreSQLとGoの型の対応

PostgreSQLの各データ型がGoではどのような型に対応するのかを表にまとめました。
公式のデータ型一覧に加えて、範囲型と配列型を記載しています。

Postgresのデータ型 DatabaseTypeName() 変数の型 変数の値(例)
bigint
(別名) int8
"INT8" int64 1234
bigserial
(別名) serial8
"INT8" int64 1234
bit [(n)] "BIT" []byte
※文字列
"00110101"
bit varying [(n)]
(別名) varbit
"VARBIT" []byte
※文字列
"00110101"
boolean
(別名) bool
"BOOL" bool true
box "BOX" []byte
※文字列
"(10,20),(0,0)"
bytea "BYTEA" []byte {1, 2, 14, 15}
character varying [(n)]
(別名) varchar [(n)]
"VARCHAR" string "abcdefghij"
character [(n)]
(別名) char [(n)]
"BPCHAR" []byte
※文字列
"abcdefghij"
cidr "CIDR" []byte
※文字列
"192.168.10.0/24"
circle "CIRCLE" []byte
※文字列
"<(10,20),30>"
date "DATE" time.Time 2006-01-02 00:00:00 +0000 +0000
double precision
(別名) floag8
"FLOAT8" float64 1.234
inet "INET" []byte
※文字列
"192.168.10.1"
integer
(別名) int
(別名) int4
"INT4" int64 1234
interval [fields] [(p)] "INTERVAL" []byte
※文字列
"01:00:00"
json "JSON" []byte
※文字列
"{\"value\": 10}"
line "LINE" []byte
※文字列
"{10,20,30}"
lseg "LSEG" []byte
※文字列
"[(10,20),(30,40)]"
macaddr "MACADDR" []byte
※文字列
"aa:bb:cc:dd:ee:ff"
money "MONEY" []byte
※文字列
"$1,234.00"
numeric [(p, s)]
(別名) decimal [(p, s)]
"NUMERIC" []byte
※文字列
"1.2340"
path "PATH" []byte
※文字列
"[(10,20),(30,40),(50,60)]"
point "POINT" []byte
※文字列
"(10,20)"
polygon "POLYGON" []byte
※文字列
"((0,0),(10,20),(20,10))"
real
(別名) float4
"FLOAT4" float64 1.234
InfNaNも取得可能
smalint
(別名) int2
"INT2" int64 1234
smallserial
(別名) serial2
"INT2" int64 1234
serial
(別名) serial4
"INT4" int64 1234
text "TEXT" string "abcdefghij"
time [(p)] [without time zone] "TIME" time.Time 0000-01-01 15:04:05 +0000 UTC
time [(p)] with time zone
(別名) timetz
"TIMETZ" time.Time 0000-01-01 15:04:05 +0700 +0700
timestamp [(p)] [without time zone] "TIMESTAMP" time.Time 2006-01-02 15:04:05 +0000 UTC
timestamp [(p)] with time zone
(別名) timestamptz
"TIMESTAMPTZ" time.Time 2006-01-02 15:04:05 +0700 +0700
tsquery "TSQUERY" []byte
※文字列
"'abc'"
tsvector "TSVECTOR" []byte
※文字列
"'abc' 'def'"
txid_snapshot "TXID_SNAPSHOT" []byte
※文字列
"10:20:10,14,15"
uuid "UUID" []byte
※文字列
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
xml "XML" []byte
※文字列
"<foo>bar</foo>"
範囲型
(例: int4range)
"INT4RANGE" []byte
※文字列
"[10,20)"
配列型
(例: integer[])
"_INT4"
※要素の型名の先頭に _ が付く
[]byte
※文字列
"{10,20}"

この表を見ると、前述のnumeric型の例だけではなく、予想以上に多くの型が[]byte型の文字列表現になることが分かると思います。それだと元の型の判別が付かず適切なデータ変換ができませんので、PostgreSQLでどのようなデータ型であったかの情報が必要です。クエリの実行結果のカーソルオブジェクトに対して ColumnTypes() を呼び出してカラムの型情報を持つスライスを取得し、更にそれぞれのカラムの型情報に対して DatabaseTypeName() を呼び出すことで、PostgreSQL上での型名称を知ることができます。

次のコードは、取得したテーブルのデータを変換する部分です。
※ついでに、後で扱いやすいように []map[string]any 型に整形しています。

// (冒頭のサンプルコードのようにデータを取得した後)
...

    // カラム情報を取得
    cols, _ := cursor.Columns()
    colTypes, _ := cursor.ColumnTypes()

    // 取得したデータを出力用データに変換
    output := make([]map[string]any, 0, len(rows))
    for _, row := range rows {
        record := map[string]any{}
        for i, v := range row {
            // 適切なデータに変換
            record[cols[i]] = convert(v, colTypes[i].DatabaseTypeName())
        }
        output = append(output, record)
    }

...

// anyの値を変換する関数
// ※ numeric型をfloat64に変換する部分のみのサンプル
func convert(v any, dbTypeName string) any {
    switch vv := v.(type) {
    case []byte:
        t := string(vv)
        switch dbTypeName {
        case "NUMERIC":
            f, err := strconv.ParseFloat(t, 64)
            if err != nil {
                return 0.0
            }
            return f
        case "XXXX":
            ...
        }
        return t
    case XXX:
        ...
    default:
        return vv
    }
}

あとは、扱う必要があるデータ型に対応するように convert 関数の switch 部分を書き足していきましょう。

Discussion