【Golang / Gorm】URLのクエリパラメータでレコードをフィルタして取得するにはどうすればいいの?


導入

最近GolangでDDDに則ったREST APIを作る勉強をしているのですが、
その中で、「DBにあるレコードをFilterするのはどうやるのが正解なんだ?」というのでかなり詰まってしまってしまいました。

普段私が使っているPythonのDjangoだと、ActiveRecordパターンが採用されているのもあり、あまり気にする機会がないのですが、
Golangだと勝手がかなり違う部分だし、ネットで探してもそれっぽいものがなかなか見つからなかったので、記事として共有させていただきます。

やりたいこと

やりたいことはいたってシンプルです。

今回はGoのチュートリアルを参考に【アルバム名】【アーティスト名】【価格】の3つのフィールドを持つレコードが存在していて、
それぞれのフィールドについて、URLの?以降の記載を基にフィルターをかけて、その結果をJSONで返してもらいます。

使う構造体はこんな感じ ↓

type Album struct {
    gorm.Model
	Title  string
	Artist string
	Price  float64
}

また、細かい要件としては下記が存在します。

  • 円表記ではなくドル表記の想定で始めてしまったため、価格に関してはint型ではなくfloat64型を使用
  • アルバム名、およびアーティスト名に関しては部分一致でもフィルタ可能にする
  • 価格のフィルタを行うために、最低価格と最高価格を設定することで、そのレンジ内の価格でアルバムをフィルタできるようにする

投げるURLの例としてはこんな感じです。

http://foo.com/api/albums?title=Yorunikakeru&artist=Yoasobi&min_price=3.00&max_price=9.99

使用した技術

APIフレームワーク Gin
ORM Gorm

1回目の挑戦

1回目の挑戦をするにあたって「なるべく構造体をそのまま使ってDBにクエリを飛ばした方がいいのか...?」と考えたので、
最低価格と最高価格でのレンジ内での検索が必要なメンバ変数Priceを除いて、
structを使いまわしてフィルタをかけるような書き方をしてみました。

下記が実際のコードです。

func filterAlbums(c *gin.Context) {
    // クエリの取得
	title := c.Query("title")
	artist := c.Query("artist")
	fp := c.Query("from_price")
	tp := c.Query("to_price")

    // もしfp(from_price)がクエリとして渡されていなかったら、
    // 0を変数に入れる
	fromPrice, err := strconv.ParseFloat(fp, 64)
	if err != nil {
		fromPrice = 0
	}
    // もしtp(to_price)がクエリとして渡されていなかったら、
    // 0を変数に入れる
	toPrice, err := strconv.ParseFloat(tp, 64)
	if err != nil {
		toPrice = 0
	}

    // DBにクエリを飛ばす用に構造体Albumに受け取った値をマッピング
	al := Album{
		Title:  title,
		Artist: artist,
	}
    // 返り値を入れるために構造体Albumのスライスを用意
	album := []Album{}

	db.Where(al).Where("price BETWEEN ? AND ?", fromPrice, toPrice).Find(&album)  // dbには *gorm.Modelが入っている
	c.IndentedJSON(http.StatusOK, album)

}

書いてみたはいいものの、こちらのコードだといくつか問題点がありました。

  1. to_priceがURLのクエリで渡されなかったときに、toPriceに0が入ってしまい、SQLが(price BETWEEN 0 AND 0)で飛ぶためにフィルタしても何も引っ掛からなくなってしまう
  2. 強引に構造体を.Where()で使おうとしているため、本来の構造体AlbumからPriceがない状態でコードに登場していて気持ちが悪い (表現力...)
  3. 構造体でフィルタしようとすると、アルバム名とアーティスト名が完全一致でしかフィルタできなかった
  4. フィルタ結果が0件だった時のレスポンスを用意していない

Golangに対して気分が勝手に期待していた点として、
「構造体を利用すればなんでも便利になるんじゃね!?」というのがあったのですが、今思うとさすがにチンパンジー過ぎました。

また、上記のコードを書きながら「.Where().Where()と条件を増やすとクエリコストが高くなっていくのでは?」と不安になりましたが、
.Where()を繋げているだけだったらクエリは飛ばずに、最後に.Find()なり.Take()なりを実行したタイミングでクエリは飛ぶので、
.Where()で条件を増やしていくこと自体は問題ないようです。

最終的なコード

というわけで、1回目の挑戦で出てきた問題点を解決したコードが下記のものとなります。

ポイントとなるところコメントで示しましたので、コードを見ていただいた後に解説していきます。

func getAlbums(c *gin.Context) {    // ポイント 1
	title  := c.Query("title")
	artist := c.Query("artist")
	minP   := c.Query("min_price")
	maxP   := c.Query("max_price")

	minPrice, err := strconv.ParseFloat(minP, 64)
	if err != nil {
		minPrice = 0
	}
	maxPrice, err := strconv.ParseFloat(maxP, 64)
	if err != nil {
		maxPrice = 0
	}

	resAl := []Album{}
	q := db                                   // ポイント2 

	if title != "" {                          // ポイント 3
		title = "%" + title + "%"
		q = q.Where("title LIKE ?", title)
	}

	if artist != "" {
		artist = "%" + artist + "%"
		q = q.Where("artist LIKE ?", artist)
	}

	if maxPrice == 0 {                                              // ポイント 4
		q = q.Where("price >= ?", minPrice)
	} else if minPrice == 0 {
		q = q.Where("price <= ?", maxPrice)                         // ポイント 5
	} else {
		q = q.Where("price BETWEEN ? AND ?", minPrice, maxPrice)
	}

	q.Find(&resAl)

    alSliceIsEmpty := len(resAl) == 0
    if alSliceIsEmpty is true {
        c.IndentedJSON(http.StatusNotFound, gin.H{"message": "album not found"})    // ポイント 6
    }

	c.IndentedJSON(http.StatusOK, resAl)
}

ポイント1:関数名の変更

どうでもいいところですが、filterAlbumsとしていた関数名をgetAlbumsに変更しました。

今回はフィルタをかける処理としてコードを書いていたので、思わずfilterと書きましたが、
URLのクエリになにも条件を入れない場合には、全件取得を期待するのが処理として自然かと考えたので、それに対応できるように関数名を変更しています。

ポイント2:*gorm.Modelをクエリ生成用に関数内の値として定義

新しくqというクエリ作成用の変数を定義しました。

中身には*gorm.Modelが入っており、記載されている通りq.Where()を繋げていくことで、
SQLへのクエリを増やしていくことが出来ます。

このようにした理由としては、後ほどの解説でも出てきますが、
1回目の挑戦の時とは違って、.Where()を継ぎ足していく書き方としています。

そのため、変数dbをそのまま使ってdb = db.Where("title LIKE ?", title)などと書いてしまうと、
グローバル変数としてのdbの値が更新され続けてしまい、
関数getAlbumsを抜けた後もdbにクエリ条件がくっついたままになってしまいます。

なので、関数getAlbums内のみで完結する変数としてqを用意し、
そこにクエリ条件をくっつけていくようにしています。

ポイント3:構造体を使ってのフィルタを断念

1回目の挑戦でも書いた通り、構造体を.Where()の引数にしてフィルタを行うと、完全一致でしかレコードを探せません。

そのため、構造体の使用をあきらめて、LIKE句を使って文字列のフィルタを行う書き方に変更しています。

フィールドの数だけ行数が多くなり煩雑な印象もありますが、「Golang/Gormの仕様上仕方ないのかな」といった印象です。

ちなみにSQLインジェクションの対策はされているのか不安になっていろいろと試してみましたが、
文字列に;が含まている場合はしっかりと弾かれるようになっていました。さすがです。ありがとうございます。

ポイント4:最低価格と最高価格の入力がない場合の処理を追加

1回目の挑戦では、最高価格の入力がないと何もレコードが引っ掛からないようになってしまいましたが、
今回はその対策をしっかりと行いました。

最高価格がURLのクエリで渡されていない場合に0を値として入れて、
それをその後のifで「maxPriceが0だったら」として処理を分岐させるのはややイケてない気もしましたが、
minPricemaxPriceで書き方を揃えた方が見やすい気がしましたので、今回はこんな書き方としました。

ポイント5:正直無駄な分岐だがelse if minPrice == 0を書く

お気づきの読者の方もいらっしゃるかもしれませんが、最後の方のelse if minPrice == 0の分岐は実は書かなくてもよくて、
その後のelseでSQLを飛ばすのと結果は一緒になります。

行数が増えてしまうのであまり良くない気もしますが、価格のフィルタを行うときに、

  1. 「minPriceのみに値が入っている場合」
  2. 「maxPriceのみに値が入っている場合」
  3. 「minPriceとmaxPriceの両方に値が入っている場合」

の3パターンがあり得ると考えたので、
その分岐があることを明示的にするにはこの書き方がいいと感じたのでこの書き方にしました。

ここに関しては人によるところかと思います...

ポイント6:レコードが見つからなかった時のレスポンスを追加

alSliceIsEmpty := len(resAl) == 0という変数にレコードが1件以上取得されたかをbool値で持たせておいて、
その結果次第でレスポンスを分けるように書きました。

まとめ

というわけでGolangのGormを使って、フィルタを行うにはどうしたらいいのか、現在の自分なりの方法を共有させていただきました。

始めは「なるべく構造体のままDBにクエリを飛ばすのがいい」と勝手に思っていましたが、
完全一致でしか探せないなど、構造体をそのままの使えることはほとんどなさそうなので、
都度URLのクエリが存在するのか確認しながら、SQLを構築していくのがよさそうです。

フィールドの数が多い場合は行数がかなり多くなりそうですが、柔軟性も考えて、クエリは上記の形で継ぎ足していく形がとりあえずは良さそうです。

ご意見や「ここ違うよ!」というのがありましたら、お気軽にコメントいただけると嬉しいです!