🐘

pgx/v5で配列型・任意の型を受け取る

に公開

配列型を受け取る

pgx/v5のstdlibを使っている場合、PostgreSQLの配列に対してGolangのスライスを渡すのは問題ないのですが、PostgreSQLの配列を受け取るときにGolangのスライスをそのまま渡すとエラーになります。

package main

import (
	"database/sql"
	"fmt"
	"log"

	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	conn, err := sql.Open("pgx", "postgres://postgres@localhost:5432/postgres")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	var val []int
	err = conn.QueryRow("SELECT $1::int[]", []int{1, 2, 3}).Scan(&val)
	//=> 2025/09/15 11:22:53 sql: Scan error on column index 0, name "int4": unsupported Scan, storing driver.Value type string into type *[]int
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(val)
}

エラーを回避するにはpgtype.NewMap()を使う必要があります。

	err = conn.QueryRow("SELECT $1::int[]", []int{1, 2, 3}).Scan(pgtype.NewMap().SQLScanner(&val))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(val) //=> [1, 2, 3]
}

また、ドライバが混在するのですがpq.Array()でも受け取れます。

	var val []sql.NullInt32
	err = conn.QueryRow("SELECT $1::int[]", []int{1, 2, 3}).Scan(pq.Array(&val))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(val) //=> [{1 true} {2 true} {3 true}]

sql.Row#Scan()のコードを追ってみると、引数がsql.Scannerを実装していた場合、そちらに処理を委譲するようになっていました。

https://github.com/golang/go/blob/080882a928c96f997a1cb67cef40d2cc6126ffcd/src/database/sql/convert.go#L393-L395

pgtype.NewMap().SQLScanner()は、まずNewMap()*pgtype.Mapを生成し、SQLScanner()がマッピングに従ってPostgreSQLの型をGolangの型に変換するsql.Scannerを返すことで、配列をスライスとして受け取れるようです。

https://github.com/jackc/pgx/blob/61d3c965ad442cc14d6b0e39e0ab3821f3684c03/pgtype/pgtype_default.go#L49-L52

ドメイン型をurl.URLで受け取る

pgtype.NewMap()が返すマップに新しい型を登録すれば、任意のPostgreSQLの型をGolangの型で受け取ることができるはずです。
ためしに、URLのドメイン型を定義して、url.URLで受け取ってみました。

https://www.postgresql.jp/document/16/html/sql-createdomain.html

URLドメイン型とテーブルを定義します。

CREATE DOMAIN url AS text;

CREATE TABLE urls (
  id    SERIAL PRIMARY KEY,
  name  VARCHAR(100),
  url   url
);

INSERT INTO urls (name, url) VALUES ('example', 'https://example.com');

URLドメイン型のOIDを調べます。

postgres=# SELECT oid FROM pg_type WHERE typname = 'url';
  oid
-------
 16403
(1 row)

GolangでURLドメイン型のコーデックを作成します。

package main

import (
	"database/sql/driver"
	"fmt"
	"net/url"

	"github.com/jackc/pgx/v5/pgtype"
)

type URLCodec struct{}

func (URLCodec) FormatSupported(format int16) bool {
	return format == pgtype.TextFormatCode
}

func (URLCodec) PreferredFormat() int16 {
	return pgtype.TextFormatCode
}

func (URLCodec) PlanEncode(m *pgtype.Map, oid uint32, format int16, value any) pgtype.EncodePlan {
	switch format {
	case pgtype.TextFormatCode:
		switch value.(type) {
		case string:
			return encodePlanURLCodecString{}
		}
	}

	return nil
}

type encodePlanURLCodecString struct{}

func (encodePlanURLCodecString) Encode(value any, buf []byte) (newBuf []byte, err error) {
	s := value.(string)
	buf = append(buf, s...)
	return buf, nil
}

func (URLCodec) PlanScan(m *pgtype.Map, oid uint32, format int16, target any) pgtype.ScanPlan {
	switch format {
	case pgtype.TextFormatCode:
		switch target.(type) {
		case **url.URL:
			return scanPlanTextAnyToURL{}
		}
	}

	return nil
}

func (c URLCodec) DecodeDatabaseSQLValue(m *pgtype.Map, oid uint32, format int16, src []byte) (driver.Value, error) {
	return c.DecodeValue(m, oid, format, src)
}

func (c URLCodec) DecodeValue(m *pgtype.Map, oid uint32, format int16, src []byte) (any, error) {
	if src == nil {
		return nil, nil
	}

	return url.Parse(string(src))
}

type scanPlanTextAnyToURL struct{}

func (scanPlanTextAnyToURL) Scan(src []byte, dst any) error {
	if src == nil {
		return fmt.Errorf("cannot scan NULL into %T", dst)
	}

	u, err := url.Parse(string(src))

	if err != nil {
		return err
	}

	p := (dst).(**url.URL)
	*p = u
	return nil
}

pgtype.MapにOID・コーデック・Golangの型を登録すると、url.URLでカラムの値を受け取れます。

package main

import (
	"database/sql"
	"fmt"
	"log"
	"net/url"

	"github.com/jackc/pgx/v5/pgtype"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	conn, err := sql.Open("pgx", "postgres://postgres@localhost:5432/postgres")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	m := pgtype.NewMap()
	m.RegisterType(&pgtype.Type{Name: "url", OID: 16403, Codec: URLCodec{}})
	m.RegisterDefaultPgType((**url.URL)(nil), "url")

	url := &url.URL{}
	err = conn.QueryRow("SELECT url FROM urls WHERE name = $1", "example").Scan(m.SQLScanner(&url))

	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%[1]s (%[1]T)\n", url) //=> https://example.com (*url.URL)
}
株式会社カンム

Discussion