🕳️

【Go】entのAggregationの意外な落とし穴にハマった話

2024/09/03に公開

株式会社Sallyエンジニアの @wellPicker です。

早速ですが、以下はユーザーが所持しているジェムとコインの合計値をprintするためのGo言語の実装例です。ORMとしてentを使用しています。

package main

import (
    "context"

    "<project>/ent"
    "<project>/ent/user"
)

func Do(ctx context.Context, client *ent.Client) {
    var v []struct {
        GemSum  int
        CoinSum int
    }
    err := client.User.Query().
        Aggregate(ent.Sum(user.FieldGem), ent.Sum(user.FieldCoin)).
        Scan(ctx, &v)

    print(v[0].GemSum)
    print(v[0].CoinSum)
}

しかし、実はこの実装では、ジェムとコインの合計値は正しくprintされません。
その理由が分かるでしょうか?

前提

ORMとは

ORM(Object-Relational Mapping)はオブジェクトとデータベースとのマッピングをおこなう技術です。これにより、SQLを直接書くことなくDB操作のメソッドを記述することができるようになります。

entとは

entはFacebookが開発したGo向けのORMです。Goのコードとしてデータベーススキーマを定義できる点や、コード生成により100%静的型付けがされることなどが特徴です。GORMなどと比べて比較的新しいORMですが、日本語のドキュメントもそこそこ充実しています。

Aggregationとは

Aggregationは日本語では「集計」と訳されます。名前からなんとなくイメージがつくかもしれませんが、ここでは複数の値の入力から一つの値を返す関数のことを指します。
SQLではCOUNT, SUM, MAX, MIN, AVGなどのAggregationを使用することが可能で、entでもこれらのAggregationを使用するためのAPIが提供されています。
entのAggregationに関する公式ドキュメントは https://entgo.io/ja/docs/aggregate/ から確認できます。

本題

業務の中で、あるテーブルの二つのフィールドに対してそれぞれの合計値を求めたいと考えて冒頭で述べたような実装を書いてみました(もっとも、冒頭の実装は説明用に簡略化したり命名を変更したりしています。今のところ、ウズで「ジェム」という概念を導入する予定はありません)。

しかし、このコードをそのまま実行すると以下のようなエラーが出ました。
"sql/scan: missing struct field for column: sum (sum)"
どうやら、SQLクエリの結果"sum"というカラムが返されているにも関わらず、Goの構造体に対応するフィールドが存在していないために怒られているようです。

では、structの定義を下のように変更すればうまく動くでしょうか?

package main

import (
    "context"

    "<project>/ent"
    "<project>/ent/user"
)

func Do(ctx context.Context, client *ent.Client) {
    var v []struct {
        GemSum  int `json:"sum"` // ここを追加してみた
        CoinSum int
    }
    err := client.User.Query().
        Aggregate(ent.Sum(user.FieldGem), ent.Sum(user.FieldCoin)).
        Scan(ctx, &v)

    print(v[0].GemSum)
    print(v[0].CoinSum)
}

勘のいい方ならお気づきだと思いますが、当然これでもうまくいきません。
エラーが返されることはなくなるものの、GemSumとCoinSumが必ず同じ値になってしまいます。
これは、ent.Sum(user.FieldGem)の結果とent.Sum(user.FieldCoin)の結果は両方ともsumという名前のカラムに入った状態で返されることが原因です。
この状態だと、structの定義をどう変えたところでそもそも返り値がジェムの合計値なのかコインの合計値なのか区別することができません。
そのため、この場合であれば後から返されたent.Sum(user.FieldCoin)の値がv[0].GemSumとv[0].CoinSumの両方に格納されてしまい、期待とは異なる結果になってしまうようです。
調べた限り、entのAggregateFuncには返り値のカラムの名前を変更するようなAPIが提供されていないようです。

解決策

では、entのAggregationでは同じ関数を同時に2回使用することはできないのでしょうか? と思われるかもしれませんが、一応この問題を回避する方法はあるようです。
調べた結果、カスタムSQL修飾子を使用することで解決できました。

package main

import (
    "context"

    "<project>/ent"
    "<project>/ent/user"
)

func Do(ctx context.Context, client *ent.Client) {
    var v []struct {
        GemSum  int `json:"gem_sum"`
        CoinSum int `json:"coin_sum"`
    }
    err := client.User.Query().
        Aggregate(
        func(s *sql.Selector) string {
            gemSum := sql.As(sql.Sum(s.C(user.FieldGem)), "gem_sum")
            coinSum := sql.As(sql.Sum(s.C(user.FieldCoin)), "coin_sum")
            return fmt.Sprintf("%s, %s", gemSum, coinSum)
        },
    ).
        Scan(ctx, &v)

    print(v[0].GemSum)
    print(v[0].CoinSum)
}

将来的には、ent.Sum(user.FieldGem).As("gem_sum")みたいな感じでAggregationの返り値のカラム名を変更できるようになってくれると嬉しいですね。entはまだ若い技術なので、今後の改善に期待したいところです。

まとめ

  • entのAggregationは、SQLクエリの返り値のカラム名を変更することができない
  • そのため、同じAggregationFuncを二つ以上使うとGoの構造体に正しく格納できない
  • カスタムSQL修飾子を使用すれば、上記の問題を一応は回避できる

Goはまだ勉強し始めてから日も浅いので、もっと良い方法をご存知の方がいればコメントお待ちしています。
これを機に、entに関する知見が深まった人がいれば幸いです。

UZU テックブログ

Discussion