gormでmany-to-manyを扱う時、リレーション先のテーブルの内容で絞り込みをする

gormで以下のようなmany-to-manyの関係を扱う時、Tagの内容で絞り込む方法がよくわからず苦戦したので記録しておく。

type Post struct {
	gorm.Model
	Title string `gorm:"column:title"`
	Tags  []Tag  `gorm:"many2many:post_tags"`
}

type Tag struct {
	gorm.Model
	Name  string `gorm:"column:name"`
	Posts []Post `gorm:"many2many:post_tags"`
}

まずは全レコード取得する場合だが、例えばこう書くと思う。

db.Debug().Find(&posts)

これを実行すると以下のようにdeleted_atがNULLのレコードを全て取得する。

[1.677ms] [rows:2] SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL
{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.753 +0000 UTC UpdatedAt:2022-01-05 03:49:24.757 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[]}
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[]}

次に、リレーションがあるテーブルの情報も構造体にマッピングする場合だが、Eager Loadingを使ってこう書く。

db.Debug().Preload("Tags").Find(&posts)

これを実行すると、以下のようにTagsにデータが入っていることがわかる。

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[2.292ms] [rows:2] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (1,2)

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[2.327ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2) AND `tags`.`deleted_at` IS NULL

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[6.615ms] [rows:2] SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL
{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.753 +0000 UTC UpdatedAt:2022-01-05 03:49:24.757 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.743 +0000 UTC UpdatedAt:2022-01-05 03:49:24.743 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]}]}
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.748 +0000 UTC UpdatedAt:2022-01-05 03:49:24.748 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

さて、問題はここからで、Tagsの中身で絞り込む場合。例えば、tag-2 のタグと関連のあるPostだけほしいような場合。いろいろ調べた結果、以下のようにPreloadとJOINを組み合わせることで実現できた。

db.Debug().
	Preload("Tags").
	Joins("JOIN post_tags ON post_tags.post_id = posts.id").
	Joins("JOIN tags ON post_tags.tag_id = tags.id").
	Where("tags.name in (?)", []string{"tag-2"}).
	Find(&posts)

実行すると以下のようにクエリが発行される。

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[1.978ms] [rows:1] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 2

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[1.952ms] [rows:1] SELECT * FROM `tags` WHERE `tags`.`id` = 2 AND `tags`.`deleted_at` IS NULL

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[7.030ms] [rows:1] SELECT `posts`.`id`,`posts`.`created_at`,`posts`.`updated_at`,`posts`.`deleted_at`,`posts`.`title` FROM `posts` INNER JOIN post_tags ON post_tags.post_id = posts.id INNER JOIN tags ON post_tags.tag_id = tags.id WHERE tags.name in ('tag-2') AND `posts`.`deleted_at` IS NULL
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.748 +0000 UTC UpdatedAt:2022-01-05 03:49:24.748 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

2022-01-07 追記:tags.nameで複数指定した場合、SQL的には複数のレコードが結果として返るが、素朴に構造体にマッピングされてしまい重複することに後で気づいた。

db.Debug().
	Preload("Tags").
	Joins("JOIN post_tags ON post_tags.post_id = posts.id").
	Joins("JOIN tags ON post_tags.tag_id = tags.id").
	Where("tags.name in (?)", []string{"tag-1", "tag-2"}).
	Find(&posts)

これを実行すると以下のようになり、ID:5の構造体がスライス内に2つあることがわかる。これだとGoからは利用しにくい。


2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[2.606ms] [rows:4] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (3,5,4)

2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[2.348ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`id` IN (3,4) AND `tags`.`deleted_at` IS NULL

2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[8.796ms] [rows:4] SELECT `posts`.`id`,`posts`.`created_at`,`posts`.`updated_at`,`posts`.`deleted_at`,`posts`.`title` FROM `posts` JOIN post_tags ON post_tags.post_id = posts.id JOIN tags ON post_tags.tag_id = tags.id WHERE tags.name in ('tag-1','tag-2') AND `posts`.`deleted_at` IS NULL
{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.754 +0000 UTC UpdatedAt:2022-01-07 01:19:28.758 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]}]}
{Model:{ID:5 CreatedAt:2022-01-07 01:19:28.777 +0000 UTC UpdatedAt:2022-01-07 01:19:28.782 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-3 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]} {Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}
{Model:{ID:4 CreatedAt:2022-01-07 01:19:28.766 +0000 UTC UpdatedAt:2022-01-07 01:19:28.77 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}
{Model:{ID:5 CreatedAt:2022-01-07 01:19:28.777 +0000 UTC UpdatedAt:2022-01-07 01:19:28.782 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-3 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]} {Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

gormの機能でどうにかできないか軽く調べたが見つからなかった。とりあえず、ワークアラウンドとしては自分で重複排除する方法がある。

m := map[uint]bool{}
uniq := []Post{}
for _, post := range posts {
	if !m[post.ID] {
		m[post.ID] = true
		uniq = append(uniq, post)
	}
}

このようにすればよい。

Github Actionsのワークフロー内でGithub Appからトークンを取得して使う

Github ActionsでGithubトークンを使う場合、自動的にGITHUB_TOKENというシークレットが作られる。このトークンでは権限が足りない、というような場合は別にトークンを用意する必要がある(自分の場合だと、他のリポジトリにコードをPushしたりPullRequestを作る用途で使っている)。誰かのパーソナルアクセストークンを使う必要があると思っていたけど、Github Appを用意して、ワークフロー内でそのAppからトークンを取得して使うことができるということを知った。

使うのは https://github.com/tibdex/github-app-token 。Example Workflowの通り、GithubAppのAPP IDとPRIVATE KEYをSecretに登録して指定するとトークンを取得してくれる。

jobs:
   job:
     runs-on: ubuntu-18.04
     steps:
       - name: Generate token
         id: generate_token
         uses: tibdex/github-app-token@v1
         with:
           app_id: ${{ secrets.APP_ID }}
           private_key: ${{ secrets.PRIVATE_KEY }}
           # Optional (defaults to the current repository).
           # repository: owner/repo
       - name: Use token
         env:
           TOKEN: ${{ steps.generate_token.outputs.token }}
         run: |
           echo "The generated token is masked: ${TOKEN}"

チームで使うリポジトリだと、自分のトークンを使わない方がよいこともあるだろうから覚えておくと便利そう。

CloudNativeDays Spring 2021でカンファレンスプラットフォームを開発した

2020年3月11日〜2020年3月12日に開催されたCloudNative Days Spring 2021 Onlineの実行委員会として、カンファレンスプラットフォームの開発に関わりました。今回は従来のRailsにReactとTypeScript・Next.jsを追加し、ウェブフロントエンドの体験を向上させたのですが、React・TypeScriptに入門できたのが個人的には大きな収穫でした。

Continue reading →

3Dプリンターでヘッドホンフックを自作したんだけど、滅茶苦茶楽しい!!

先日、思い立って3Dプリンターを買っていろいろプリントしていたんだけど、自分でヘッドホンをデスクにひっかけるためのフックを作ってみた、という話です。こういうやつ。

そもそも、10月に3Dプリンターを買っていた。

最初はthingiverse にアップロードされている既存のモデルを印刷していたのだけど、自分の家にフィットするものを作りたくなったので自分でモデルも作ってみることにした。

とはいえ、何もない状態からモデルを作るのはハードルが高かったので、既存のモデルを参考にしつつ作業した。選んだモデルはこれ

https://www.thingiverse.com/thing:411962

3Dモデリングには 3D Shapr を選んだ。iPadとApplePencilを使ってモデリングできて、知識ゼロでもなんとなく使えたのでこれにした。Fusion360のようなmacOSで使えるソフトウェアもあるが、いまいち使い方がわからなかった。3D Shaprは月額制のサブスクリプションで結構高いのだが、学習と思って今のところ支払っている。そのうちFusion360とかに移行できるといいなと思う。

試作1号機

最初に作ったのがこれ。直方体をつなげただけ。ダサい。

しかし、ヘッドホンフックとしては一応機能する。ダサいけど。

試作2号機

2号機。工夫した点はサポートするための支え?の追加と、プレートの先の部分を細くしてスマートにした点。

これは結構いい感じだが、ヘッドホンが机の外側にはみ出ていると邪魔では?というコメントをもらった。

試作3号機

3号機。デスクの下に収まるように設計してみた。

ヘッドホンの位置はバッチリだが、机への取り付け部分のサイズが甘くスカスカになってしまった。

完成品

これが完成形。3号機とほぼ同じで、寸法の微調整をした。

デスクの天板の高さにぴったりでめちゃ良い。

さらに、たまたま手元にあったパーマセルテープを貼って外れにくくした。

終わりに

3Dプリンター、面白い。ぶっちゃけヘッドホンフックとか買った方が早くて、自分で作るのって車輪の再発明みたいなものなんだよね。でも、自分が使うものを自分のために自分で作るという点についてはソフトウェア開発に通じるものがあって楽しい。そのうち、探してもないものを作るように慣れればいいなと思う。

しかし、モデルを作るのが難しい。3DShaprはかなり直感的に扱えるけど、それでも「こういう操作をしたらこうなりそう」というiPad的な感覚とは離れている箇所もある。

CloudNativeDaysTokyo2020でカンファレンスプラットフォームを開発した

9月8日と9月9日に開催されたCloudNative Days Tokyo 2020の実行委員として、カンファレンスプラットフォームを開発しました。とても楽しく、良い経験ができました。

もともと、実行委員としては去年の末ごろから参加して準備を手伝っていました。その時の主な担当はウェブサイトで、Hugoで作ったりしていた。あとはコンテンツ周りも関わっていたかな。

これまでカンファレンスで登壇はしてきたのですが、開催する側はやったことがなかったんですね。ミートアップくらい。なので、開催する側もやってみたくて参加ました。

結果として、これまでのオフラインイベントの開催は体験できませんでした。しかし、オンラインカンファレンスという新しい体験に関わり、とても楽しく良い経験ができました。

cndt2020は去年と同様オフラインイベントの予定だったのだけど、新型コロナウイルスの流行により、オンラインイベントに変更しました。そして、オンライン化にあたり配信などをどうやるかが課題となりました。

いくつかのサービスを検討したが、結果としては自分たちで作る決断をし、自分は決断した後、7月ごろから開発に参加しました(雑誌の執筆でバタバタしていた)。

技術スタックとしてはRailsとHeroku、Auth0、Vimeo、sli.doを使用。時間もあまりなかったので、外に出せるものは出した。そのため技術的にめちゃくちゃ難しい、というものはそこまでなくて(websocket周りの経験が無かったくらい?)、素朴にモデル設計やUIをどうやるかというところが難しかった。

開発メンバーはメイン2名、スポットで時々参加する人が数人で、デスマーチではないがやや大変だったかなという印象です。

カンファレンスプラットフォームとして大事にしたのは使い勝手で、プレイベントのrejektsで分かったUIの問題などを本番までに改修できたのはよかった。

本番のイベント中は負荷の監視をしたり参加者のフィードバックやアイデアを実装、デプロイしていました。素早い対応ができたのは自前実装の利点ですね。

今後、カンファレンスで同じプラットフォームを使うとなると破壊的変更が難しくなるので、エンジニアリングとしても難易度は上がりそう。UIをよりよくするためにはReactやVueのようなフロントエンドフレームワークが必要かもしれないという話をしていて、ここは新たなチャレンジじゃないかなと思います。