sqlxで名前付き埋め込み構造体をJoinする

ぺりー
19 min readMar 23, 2023

--

https://www.flaticon.com/free-icons/mysql

sqlxで名前付き埋め込み構造体をJoinする際には少しハマったので内部実装に触れながらまとめておきます。

xormの挙動

以前利用していたライブラリxormでは、名前付き埋め込みが可能でした。

xormの場合はextendsタグをつけることで名前付き埋め込み構造体をJoinすることができます。

type UserSegment struct {
User User `xorm:"extends"`
Segment Segment `xorm:"extends"`
}

type User struct {
ID int64
Name string `xorm:"varchar(25) not null unique 'name'`
SegmentID int64
}

type Segment struct {
ID int64
Name string `xorm:"varchar(25) not null unique 'name'`
}

https://xorm.io/docs/chapter-05/5.join/

背景

リファクタリングの一環でxormからsqlxに移行していたため、バグを減らすために元の挙動は変更してはいけませんでした。
また、ベースのコードを変更すると差分が増えてしまう上に差分の意味がぼやけてしまうので、今回の変更ではなるべくベースコードを変えない方が差分がわかりやすくレビュワーの負担軽減にもつながります。

sqlxの通常の埋め込み

sqlxでも通常の埋め込みであれば、既存のコードになんの変更も必要ありません。

type UserSegment struct {
User
Segment
}

type User struct {
UserID int64 `db:"user_id"`
UserName string `db:"user_name"`
SegmentID int64 `db:"segment_id"`
}

type Segment struct {
ID int64 `db:"id"`
SegmentName string `db:"segment_name"`
}

既存のコードで名前付き埋め込み構造体で実装されていたのは埋めこんだ際に構造体のfieldの衝突が起きてしまうためでした。

名前付き埋め込み構造体を使わない方法

下記のように通常の埋め込みで実装することもできますが、先に述べた理由に加えて、 DBのスキーマととfield名の不一致を避けたほうがよりコードをシンプルに保つことができます。(User/Segmentテーブルのカラムはid, nameであるため、Joinしない場合は必ずASでエイリアスをつける必要があります。)

type UserSegment struct {
User
Segment
}

type User struct {
UserID int64 `db:"user_id"`
UserName string `db:"user_name"`
}

type Segment struct {
SegmentID int64 `db:"segment_id"`
SegmentName string `db:"segment_name"`
}

他にもJoinするケースの別の構造体を定義する方法もありますが、先に述べた理由に加え、データベースのスキーマを変更した際に別の構造体の変更が漏れてしまうことが考えられるため、どちらも積極的にとりたい方法ではありませんでした。

// UserとSegmentをJoinした構造体.
type UserSegment struct {
UserID int64 `db:"user_id"`
UserName string `db:"user_name"`
SegmentID int64 `db:"segment_id"`
SegmentName string `db:"segment_name"`
}

type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
SegmentID int64 `db:"segment_id"`
}

type Segment struct {
ID int64 `db:"id"`
Name string `db:"name"`
}

名前付き埋め込み構造体を使う方法

少し脇道に逸れてしまいましたが、名前付き埋め込み構造体をJoinして取得するには下記のようにする必要がありました。

type UserSegment struct {
User User `db:"user"`
Segment Segment `db:"segment"`
}

type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
SegmentID int64 `db:"segment_id"`
}

type Segment struct {
ID int64 `db:"id"`
Name string `db:"name"`
}

func (repository) GetUserSegment(ctx context.Context, userID, segmentID int64) ([]UserSegment, error) {
var ents []UserSegment
err := sqlx.SelectContext(ctx, db, &ents,
"SELECT `user`.`id` AS 'user.id', "+
"`user`.`name` AS 'user.name', "+
"`segment`.`id` AS `segment.id`, "+
"`segment`.`name` AS `segment`.`name` "+
"FROM `user` "+
"JOIN `segment` ON `user`.`segment_id` = `segment`.`id`;")
return &ents, err
}

冗長に AStable.column を指定しています。

AS句がない場合は以下のコードになりますが、エラーが発生してしまいます。

type UserSegment struct {
User User `db:"user"`
Segment Segment `db:"segment"`
}

type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
SegmentID int64 `db:"segment_id"`
}

type Segment struct {
ID int64 `db:"id"`
Name string `db:"name"`
}

func (repository) GetUserSegment(ctx context.Context, userID, segmentID int64) ([]UserSegment, error) {
var ents []UserSegment
err := sqlx.SelectContext(ctx, db, &ents,
"SELECT `user`.`id`, `user`.`name`, `segment`.`id`, `segment`.`name` "+
"FROM `user` JOIN `segment` ON `user`.`segment_id` = `segment`.`id`;",
)
return &ents, err
}
missing destination name id in *[]usersegment.User

解説

sqlxのコードを少し見てみます。

// SelectContext executes a query using the provided Queryer, and StructScans
// each row into dest, which must be a slice. If the slice elements are
// scannable, then the result set must have only one column. Otherwise,
// StructScan is used. The *sql.Rows are closed automatically.
// Any placeholder parameters are replaced with supplied args.
func SelectContext(ctx context.Context, q QueryerContext, dest interface{}, query string, args ...interface{}) error {
rows, err := q.QueryxContext(ctx, query, args...)
if err != nil {
return err
}
// if something happens here, we want to make sure the rows are Closed
defer rows.Close()
return scanAll(rows, dest, false)
}

https://github.com/jmoiron/sqlx/blob/28212d434cdd87418ecd0cb81173690ce7ac6ab6/sqlx_context.go#L49-L62

AS句がなくても、SQLのsyntaxは問題ない上、エラーメッセージから取得後に構造体にマッピングをしているsqlx.scanAll()を読めばいいことがわかります。

sqlx.scanAll() のなかのmissingFields付近で上記エラーと同じ文言を返している箇所があり、今回はsql.Scannerインターフェースを実装していないため、上記エラーを返している箇所だとわかります。

func scanAll(rows rowsi, dest interface{}, structOnly bool) error {
// -----------------------------
scannable := isScannable(t)
// -----------------------------
if !scannable {
// ---------------------------
fields := m.TraversalsByName(base, columns)
// if we are not unsafe and are missing fields, return an error
if f, err := missingFields(fields); err != nil && !isUnsafe(rows) {
return fmt.Errorf("missing destination name %s in %T", columns[f], dest)
}
} else {
// ---------------------------
}
// --
}
func isScannable(t reflect.Type) bool {
if reflect.PtrTo(t).Implements(_scannerInterface) {
return true
}
if t.Kind() != reflect.Struct {
return true
}

// it's not important that we use the right mapper for this particular object,
// we're only concerned on how many exported fields this struct has
return len(mapper().TypeMap(t).Index) == 0
}

isScannable(t reflect.Type) bool

https://github.com/jmoiron/sqlx/blob/28212d434cdd87418ecd0cb81173690ce7ac6ab6/sqlx.go#L57

まずmissingFieldsより先に(*Mapper).TraversalsByName()の実引数 columns をみてみると、stringのリストになっていて、AS句を指定した場合はそのエイリアスが、指定しなかった場合はコンフリクトするカラム名が入っていることがわかりました。

 .anon0: []string len: 4, cap: 4, [
"id", // SELECT `id` ...
"user.name", // SELECT `user`.`name` AS 'user.name'
]

次に(*Mapper).TraversalsByName()の実装を見ていくと、構造体にmappingされた名前に対してカラム名に対応する構造体のIndexを返し、対応する構造体が見つからない場合は空のスライスを返すようです。

// TraversalsByName returns a slice of int slices which represent the struct
// traversals for each mapped name. Panics if t is not a struct or Indirectable
// to a struct. Returns empty int slice for each name not found.
func (m *Mapper) TraversalsByName(t reflect.Type, names []string) [][]int {
r := make([][]int, 0, len(names))
m.TraversalsByNameFunc(t, names, func(_ int, i []int) error {
if i == nil {
r = append(r, []int{})
} else {
r = append(r, i)
}

return nil
})
return r
}

// TraversalsByNameFunc traverses the mapped names and calls fn with the index of
// each name and the struct traversal represented by that name. Panics if t is not
// a struct or Indirectable to a struct. Returns the first error returned by fn or nil.
func (m *Mapper) TraversalsByNameFunc(t reflect.Type, names []string, fn func(int, []int) error) error {
t = Deref(t)
mustBe(t, reflect.Struct)
tm := m.TypeMap(t)
for i, name := range names {
fi, ok := tm.Names[name]
if !ok {
if err := fn(i, nil); err != nil {
return err
}
} else {
if err := fn(i, fi.Index); err != nil {
return err
}
}
}
return nil
}

今回の例では、m.TypeMap(t)(つまりtm)の返り値に下記のオブジェクトが返却されました。

 ~r0: *github.com/jmoiron/sqlx/reflectx.StructMap {
Tree: *github.com/jmoiron/sqlx/reflectx.FieldInfo {
// --------------------
Children: []*github.com/jmoiron/sqlx/reflectx.FieldInfo len: 3, cap: 3, [
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a0f0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a1e0),
],
// --------------------
Index: []*github.com/jmoiron/sqlx/reflectx.FieldInfo len: 28, cap: 32, [
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a0f0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a1e0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a2d0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a3c0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a4b0),
*(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a5a0),
],
Paths: map[string]*github.com/jmoiron/sqlx/reflectx.FieldInfo [
"segment.id": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a5a0),
"segment.name": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a870),
"segment": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042ab40),
"user.id": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042aff0),
"user.name": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042b2c0),
"user": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a2d0),
],
Names: map[string]*github.com/jmoiron/sqlx/reflectx.FieldInfo [
"segment": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a0f0),
"segment.id": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a3c0),
"segment.name": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042a5a0),
"user": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042aa50),
"user.id": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042ad20),
"user.name": *(*"github.com/jmoiron/sqlx/reflectx.FieldInfo")(0x1400042ae10),
],}

上記オブジェクトのNamesフィールドのキーに合わない場合は下記のイメージにある通り[][]int{{},{}...}のようなスライスが返却されます。

// ASを使わない場合.
columns := []string{"id", "name", "id", "name"}
tm.Names := map[string]*reflextx.FieldInfo{
"segment": ...,
"segment.id": ...,
"segment.name": ...,
"user": ...,
"user.id": ...,
"user.name": ...,
}
for i, column := range columns {
fi, ok := tm.Names[column]
if !ok {
r = append(r, []int{})
continue
}
}
return r

その結果、呼び出しもとでmissingFields[][]int{{},{}...}のようなスライスが実引数として渡されてエラーになります。

// --------------
// if we are not unsafe and are missing fields, return an error
if f, err := missingFields(fields); err != nil && !isUnsafe(rows) {
return fmt.Errorf("missing destination name %s in %T", columns[f], dest)
}
// --------------

func missingFields(transversals [][]int) (field int, err error) {
for i, t := range transversals {
if len(t) == 0 {
return i, errors.New("missing field")
}
}
return 0, nil
}

以上を踏まえると、Namesフィールドのキーに合うようにAS句を組み立てないといけないため、冗長になりますが下記のようにSQLを組み立てる必要があります。

SELECT
`user`.`id` AS 'user.id',
`user`.`name` AS 'user.name',
`segment`.`id` AS `segment.id`,
`segment`.`name` AS `segment`.`name`
FROM
`user`
JOIN
`segment`
ON
`user`.`segment_id` = `segment`.`id`;

--

--

ぺりー
ぺりー

Written by ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.

No responses yet