5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

sqlcでType Overrides の活用とgo-optionalの話

Last updated at Posted at 2024-09-12

前回前々回に続き、sqlcの話です。もう少し続きます。

Type Overrides

sqlcは、YAMLで設定を書きますが、overridesという項目があります(ドキュメント)。これは何かというと、DBの型に対するGoの型(例えば、VARCHARstringにする)のデフォルトを上書きできるというものです。

ちなみに、実際これを使わないと、MySQLのDECIMALはGoのstringになってしまいます。

MySQLのDECIMALにGoの型(decimal.Decimal)を指定する

MySQLの型をGoの型で置き換えるためには、sqlc.ymlのoverridesに、以下のように書きます。

            overrides:
                - db_type: decimal
                  go_type:
                    import: github.com/shopspring/decimal
                    type: Decimal

これで、mysqlのdecimalは、github.com/shopspring/decimalDecimalにマッピングされます。

null可のカラムなら、nullable: trueをつけて、typeも、NullDecimalも定義しましょう。

                - db_type: "decimal"
                  nullable: true
                  go_type:
                    import: "github.com/shopspring/decimal"
                    type: "NullDecimal"

特定のカラムにGoの型を指定する

先程は、DBの特定の型に対してGoの型を指定してoverrideしましたが、カラムを指定することも出来ます。

            overrides:
                - column: hogehoge.id
                  go_type:
                    import: your-project/domain/valueobject
                    type: HogehogeID

columnのところは、テーブル名.カラム名 にしないといけません(試してないけど、スキーマ名.テーブル名.カラム名もいけるはず)。なお、db_typeとは排他的ですので、同時に指定することは出来ません。

自前の型でoverrideすることで、たとえば、TaskStatus のような型でdefaultのint8をoverrideしてやれば、その型にIsFinished とか、IsPlannedのようなメソッドを用意しておくことも出来ます。

ただ、typeが構造体の場合は、ScanValueを実装する必要があります。

型にScan/Valueを実装する

例えば、とあるカラムにJSONが入っていたとします。DBから取り出した時点でUnmarshalされていれば、とても便利ですね。そういう時は、自分で定義した型にScanを実装します。

package valueobject

import (
	_ "database/sql"
	"database/sql/driver"
	"encoding/json"
	"fmt"
	_ "unsafe"
)

type Config struct {
	Hoge struct {
		URL string `json:"url"`
	} `json:"hoge"`
}

func (c *Config) Scan(src any) error {
	if src == nil {
		*c = Config{}
		return nil
	}

	var v string
	err := sqlConvertAssign(&v, src)
	if err != nil {
		return err
	}
	data := &Config{}
	err = json.Unmarshal([]byte(v), &data)

	if err != nil || data == nil {
		*c = Config{}
		return nil
	}

	*c = *data

	return nil
}

func (c *MallConfig) Value() (driver.Value, error) {
	bytes, err := json.Marshal(c)
	if err != nil {
		return nil, err
	}
	return string(bytes), nil
}

sqlConvertAssign を別ファイルで定義しておくと良いです。

package valueobject

import (
	_ "database/sql"
	_ "unsafe"
)

//go:linkname sqlConvertAssign database/sql.convertAssign
func sqlConvertAssign(dest, src any) error

go-optional の話

最初に、NullDecimalが出てきましたが、NullDecimalsql.Null***などを使うところを、@moznionさんのgo-optionalで置き換えるととても良いです(ちなみに、前段のScanValueはgo-optionalのコードを参考にしていますというか、一部そのままです)。

go-optional は、Genericsを使ってGoでOption型を実現しています。

通常よく使われている、sql.NullStringや、sql.NullInt は、Valid メソッドで値がnilかどうかをチェックすることになっているのですが、構造体のメンバに、intstringが入っているため、初期値として、0空文字が入っています。要は、チェックしなくても使えてしまいます

hoge := sql.NullString{Valid:false, String:""}
if ! hoge.Valid() {
  // nil扱い
}

なんですけど、

hoge := sql.NullString{Valid:false, String:""}
str := hoge.String 

と書けてしまうので、イマイチ感が拭えません。

overridesで go-optional を使ってみましょう。

          - db_type: "string"
            nullable: true
            go_type:
              import: "github.com/moznion/go-optional"
              type: "Option[string]"

こうすることにより、下記のように書けます。

str, err := hoge.Take()
if err != nil {
  // hogeはnil
}

もちろん、Take()を使わずに、IsSome()でチェックせずに、Unwrap()してしまう可能性はゼロではありませんが、Takeを使うとチーム内で決めてしまえば、コードレビューで防げるレベルかと思います。

注意事項

go_type には、import が1つしか書けません。ので、go-optionalを使って、type: Option[valueobject.YourType]のようにした場合、github.com/moznion/go-optional と、your-project/domain/valueobject の2つをimportする必要があります。

その場合は、下記のようにして、overridesに、YourOptionType、うまく動きます。

type YourOptionType = optional.Option[YourType]

この場合、YourOptionTypeに独自のメソッドを書くことは出来ません。当初デメリットかと思っていましたが、UnWrapすれば使えるので特に問題ないと思います。

ちなみに、下記の定義ではうまくいきません。

type YourOptionType optional.Option[YourType]

query/以下のファイルを分割しない場合はOption[valueobject.*] ではない、valueobject.*の型を一つでも定義しておけば、そこでimportはされるので、特に問題無いです。生成されるのは一つのファイルなので、必要なimportがoverridesの定義の中に1つでも現れていれば特に問題ないわけです。

「いや、全部 null可なんだけど...」という場合は、非常にダサい解決策として、ダミーのテーブルを用意して、そこに、null可ではない、独自型のカラムを適当に用意してやって、overridesしてやれば、なんとかなるとは思います。

別のworkaroundとして、importに改行付きで2つ書く(import: "time"\n"github.com/myproject/null"のように)...という手段があるようですが、まともに動くのかな...?

終わり

sqlcのType Overridesと関連してgo-optionalの話でした。どちらもなかなか便利ですし、組み合わせると最高ですので、積極的に使っていくと良いのではないでしょうか。

ただ、いろんな場所で使われているカラムをoverridesで書いていくのは、すごく、だるいですね。テーブル増えたらどうすんねん...という気分になります。

そうですね、人間がやることではないです。自動化しましょう。次回に続きます。

5
3
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
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?