GoでMongoDBのネストされたソートカラムを動的に組み立てる

ぺりー
20 min readJan 9, 2024

あけましておめでとうございます、ぺりーです。

UnsplashSandy Millarが撮影した写真

今回はreflectionパッケージを使用してネストされたソートカラムを動的に組み立てたので備忘録のために記事にしました。

前提

MongoDBはひとつのドキュメント(RDBでいうところのレコード)にサブドキュメントや配列をネストして保持することが可能です。

ネストされたオブジェクトをキーにコレクションをソートする場合、. を区切り記号として使用して、フィールド名を連結する必要があります。

例えば以下のドキュメントで作成者の名前にアクセスするには author.name を使用します。

{
"id": "123-456-222",
"author": {
"name": "Jane Doe",
"birthday": "1999/10/10"
},
"editor": {
"name": "Jane Smith",
"birthday": "1999/10/20"
},
"title": "The Ultimate Database Study Guide 1st Edition"
}

実現すること

ネストされたオブジェクトのフィールド名が連結されずに渡された場合、連結したフィールド名を返すようにします。

コレクションをページネーションで取得するリストAPIとソートAPIがある仮定します。

ソートAPIではリストAPIのレスポンスに含まれるキー名を使用しますが、キー名をそのまま使用した場合には正しくソートできません。

curl '/api/list'
[
{
"id": "123-456-222",
"name": "Jane Doe",
"title": "The Ultimate Database Study Guide 1st Edition"
},
{
"id": "123-456-222",
"name": "Jose Doe",
"title": "The Ultimate Database Study Guide 2st Edition"
}
]

例えば上記のようにドキュメントのネスト構造を崩して返している場合、ソートAPIのクエリパラメータでソートキーを name に設定すると、Backend側で author.name として解釈し、ソートした結果を返却する必要があります。

*リストAPIで name キーを author.name キーとして返却するなどして差分を吸収することはできますが、ドキュメントの構造に影響を受けやすいため採用せずソート側で修正するとします。

実装方法

  1. ルールベースで置換する
  2. reflectionを使って動的に置換する

今回は共通メソッドとして呼び出されている箇所が多かったのとreflectionパッケージを使用した際のパフォーマンスイシューが問題にならなかったので、ルールベースではなく、動的に検索して置換するようにしました。

次に幅優先探索と深さ優先探索どちらで検索するかを考えます。
下記例で name を検索した場合に幅優先探索では本の名前、深さ優先探索では著者の名前が返ります。

type Book struct {
ID string `bson:"_id"`
Author Author `bson:"author"`
Name string `bson:"name"`
Description string `bson:"description"`
PublishedAt int64 `bson:"published_at"`
}

type Author struct {
ID string `bson:"_id"`
Name string `bson:"name"`
}

今回は、階層が高い方を優先して検索したかったので、幅優先探索を持ちいることにしました。

深さ優先探索の場合はネストが深いと高い階層のフィールドを検索しても検索量が増えてしまうので注意してください。

実装

色々な実装方法が考えられますが、今回は bson タグとリクエストパラメータが一致した場合に、ネストした構造体であれば連結した bson タグを返し上書きするように実装しました。

AdjustSearchQueryParameters(&SearchQueryParams, &Entity{})

一致しない場合は、リクエストパラメータをそのまま返すため、上書きしません。

また、不完全な連結タグ( parent.name (誤)の時に author.parent.name (正)を返すよう)に対応しました。

func AdjustSearchQueryParameters[T any](params *SearchQueryParameters, ptr *T) {
t := reflect.TypeOf(ptr).Elem()
if t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
}
// struct 以外では panic するので何もしない.
if t.Kind() != reflect.Struct {
return
}
for i, v := range params.Orderby {
tag, ok := findTag(v.Field, typeInfo{typ: t})
if ok {
params.Orderby[i].Field = tag
}
}
}

type typeInfo struct {
typ reflect.Type
parent string
}

// 幅優先探索でネストされた構造体からタグを探し、親のタグ名と連結して返す.
// . でネストされたタグは重複したフィールド名考慮して検索する.
// 見つからない場合は tag をそのまま返す.
func findTag(tag string, info typeInfo) (string, bool) {
// struct 以外では panic するので何もしない.
if info.typ.Kind() != reflect.Struct {
return tag, false
}
parent := info.parent
structs := make([]typeInfo, 0, info.typ.NumField())
for i := 0; i < info.typ.NumField(); i++ {
field := info.typ.Field(i)
column := field.Tag.Get("bson")
// time.Time の場合はフィールドとして扱う.
if field.Type.Kind() == reflect.Struct && field.Type.String() != "time.Time" {
if column == "" {
// unamed embedded struct の場合は自身のタグがないので親のタグ名を引き継ぐ.
structs = append(structs, typeInfo{typ: field.Type, parent: parent})
continue
}
structs = append(structs, typeInfo{typ: field.Type, parent: parent + column + "."})
continue
}
// struct が pointer の場合は Elem() で実体を取得する.
if field.Type.Kind() == reflect.Ptr &&
field.Type.Elem().Kind() == reflect.Struct &&
field.Type.Elem().String() != "time.Time" {
if column == "" {
// unamed embedded struct の場合は自身のタグがないので親のタグ名を引き継ぐ.
structs = append(structs, typeInfo{typ: field.Type.Elem(), parent: parent})
continue
}
structs = append(structs, typeInfo{typ: field.Type.Elem(), parent: parent + column + "."})
continue
}
if column == tag {
return parent + column, true
}
// tag が . でネストされている場合は重複したフィールド名を考慮するため suffix を比較する.
if l := len(strings.Split(tag, ".")); l > 1 {
if strings.HasSuffix(parent+column, tag) {
return parent + column, true
}
}
}
for _, s := range structs {
if v, ok := findTag(tag, s); ok {
return v, true
}
}
return tag, false
}

実際の使い方は下記のテストをご覧ください。

func TestAdjustSearchQueryParameters(t *testing.T) {
type (
contentsTemplate struct {
ID string `json:"id" bson:"id"`
Name string `json:"name" bson:"name"`
}
attr struct {
Timestamp int64 `json:"timestamp" bson:"timestamp"`
}
comment struct {
Text string `json:"text" bson:"text"`
attr
}
contentsUser struct {
Group *string `json:"group,omitempty" bson:"group"`
ID string `json:"id,omitempty" bson:"id"`
}
contentsType string
contents struct {
UserID string `bson:"user_id"`
Type contentsType `json:"type" bson:"type"`
Tag []string `json:"tag" bson:"tag"`
Name string `json:"name" bson:"name"`
SourceContentsTemplate *contentsTemplate `json:"source_contents_template" bson:"source_contents_template"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
UpdatedBy contentsUser `json:"updated_by" bson:"updated_by"`
DeletedAt *time.Time `json:"deleted_at" bson:"deleted_at"`
comment
}
meta struct {
Data string `json:"data"`
}
OriginalContents struct {
ID string `bson:"_id"`
Contents contents `bson:"contents"`
Temporary bool `bson:"temporary"`
UserID string `bson:"user_id"`
meta
}
)
tests := []struct {
name string
params *SearchQueryParameters
ptr any
want *SearchQueryParameters
}{
{
name: "simple",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "_id"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "_id"}},
},
},
{
name: "not found",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "notfound"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "notfound"}},
},
},
{
name: "named embedded duplicated name",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "user_id"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "user_id"}},
},
},
{
name: "named embedded",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "tag"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.tag"}},
},
},
{
name: "named embedded pointer",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "deleted_at"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.deleted_at"}},
},
},
{
name: "named embedded pointer struct",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "id"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.source_contents_template.id"}},
},
},
{
name: "named embedded unnamed embedded 1",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "text"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.text"}},
},
},
{
name: "named embedded unnamed embedded 2",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "timestamp"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.timestamp"}},
},
},
{
name: "unnamed embedded",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "data"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "data"}},
},
},
{
name: "specify duplicated tag name",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "updated_by.id"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.updated_by.id"}},
},
},
{
name: "slice struct version:simple",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "_id"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "_id"}},
},
},
{
name: "slice struct version:not found",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "notfound"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "notfound"}},
},
},
{
name: "slice struct version:named embedded duplicated name",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "user_id"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "user_id"}},
},
},
{
name: "slice struct version:named embedded",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "tag"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.tag"}},
},
},
{
name: "slice struct version:named embedded pointer",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "deleted_at"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.deleted_at"}},
},
},
{
name: "slice struct version:named embedded pointer struct",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "id"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.source_contents_template.id"}},
},
},
{
name: "slice struct version:named embedded unnamed embedded 1",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "text"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.text"}},
},
},
{
name: "slice struct version:named embedded unnamed embedded 2",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "timestamp"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.timestamp"}},
},
},
{
name: "slice struct version:unnamed embedded",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "data"}},
},
ptr: &[]OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "data"}},
},
},
{
name: "slice struct version:specify duplicated tag name",
params: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "updated_by.id"}},
},
ptr: &OriginalContents{},
want: &SearchQueryParameters{
Orderby: []OrderbyElem{{Field: "contents.updated_by.id"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.ptr.(type) {
case *OriginalContents:
AdjustSearchQueryParameters(tt.params, tt.ptr.(*OriginalContents))
case *[]OriginalContents:
AdjustSearchQueryParameters(tt.params, tt.ptr.(*[]OriginalContents))
}
if !reflect.DeepEqual(tt.params, tt.want) {
t.Errorf("got %v, want %v", tt.params, tt.want)
}
})
}
}

最後に

より厳密にやる必要があれば、bsonタグではなく、独自のタグ( sort:”name” )を設定するなどしてエンティティとレスポンスの差分を考えなくするのが良いのではないでしょうか。

今回の実装ではユーザーが任意のソートキーを使用できるため、注意してください。

--

--

ぺりー

Satoru Kitaguchi. Backend Engineer at eureka, Inc.