Type Overrides
sqlcは、YAMLで設定を書きますが、overrides
という項目があります(ドキュメント)。これは何かというと、DBの型に対するGoの型(例えば、VARCHAR
をstring
にする)のデフォルトを上書きできるというものです。
ちなみに、実際これを使わないと、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/decimal
の Decimal
にマッピングされます。
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が構造体の場合は、Scan
とValue
を実装する必要があります。
型に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
が出てきましたが、NullDecimal
やsql.Null***
などを使うところを、@moznionさんのgo-optionalで置き換えるととても良いです(ちなみに、前段のScan
とValue
はgo-optionalのコードを参考にしていますというか、一部そのままです)。
go-optional は、Genericsを使ってGoでOption型を実現しています。
通常よく使われている、sql.NullString
や、sql.NullInt
は、Valid
メソッドで値がnilかどうかをチェックすることになっているのですが、構造体のメンバに、int
やstring
が入っているため、初期値として、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で書いていくのは、すごく、だるいですね。テーブル増えたらどうすんねん...という気分になります。
そうですね、人間がやることではないです。自動化しましょう。次回に続きます。