Ecto 2.0.0-betaを試す (2)
第2弾です。
今回はリレーションです。以前からあるhas_one
, has_many
を復習し、新たに入ったmany_to_many
を試してみます。
TL;DR
これ書いてる時点のコードはこちら。
has_one
User
has_one
Profile
を作っていきます。User
は前回つくったので割愛。
Profile
には、自己紹介文とgithubのURLを入れましょう。migrationはこんな感じ。
defmodule PlayEcto.Repo.Migrations.AddProfilesTable do use Ecto.Migration def change do create table(:profiles) do add :self_introduction, :text add :github_url, :string add :user_id, references(:users) timestamps end create unique_index(:profiles, [:github_url]) end end
add :user_id, references(:users)
がポイント。外部キーが定義されます。
Profile
モジュールは以下。バリデーションは特に入れてないです。
defmodule PlayEcto.Profile do use Ecto.Schema import Ecto.Changeset alias PlayEcto.User schema "profiles" do field :self_introduction, :string field :github_url, :string belongs_to :user, User # ← これ大事 timestamps end @allowed ~w[self_introduction github_url] def changeset(model, params) do model |> cast(params, @allowed) end end
次に、User
モジュールにhas_one
を追記し、changeset/2
のパイプ列に|> cast_assoc(:profile)
を追加します。
defmodule PlayEcto.User do ... schema("users") do ... has_one :profile, Profile timestamps end ... def changeset(model, params) do model |> cast(params, @allowed) |> cast_assoc(:profile) |> hash_password |> validate_required(:name) |> validate_required(:password_hash) end ... end
cast_assoc(changeset, :profile)
は、changeset.params
から:profile
キーを引いてきて、Profile.changeset/2
を使ってchangesetを作ってくれます。つまりProfile.changeset(%Profile{}, changeset.params)
を作って関連付けてくれます。これによって、
params = %{ name: "Joe-noh", password: "secret-password", profile: %{ self_introduction: "こういう者です。", github_url: "https://github.com/Joe-noh" } } User.changeset(%User{}, params) |> Repo.insert!
とするとUser
と一緒にProfile
もinsertできるようになります。便利。Profile.changeset/2
以外の関数を使ってほしいときは、
cast_assoc(:profile, with: &Profile.custom_function/2)
とwith
オプションで指定すればよいです。
has_many
ほぼhas_one
と同じなのでサクッと。User
has_many
Post
で行きます。
defmodule PlayEcto.Repo.Migrations.AddPostsTable do use Ecto.Migration def change do create table(:posts) do add :title, :string add :body, :text add :user_id, references(:users) timestamps end create index(:posts, [:user_id]) end end
defmodule PlayEcto.Post do use Ecto.Schema import Ecto.Changeset alias PlayEcto.User schema "posts" do field :title, :string field :body, :string belongs_to :user, User timestamps end @allowed ~w[title body] def changeset(model, params) do model |> cast(params, @allowed) |> validate_required(:title) |> validate_required(:body) |> validate_required(:user_id) end end
で、使い方はこんな。
# まずはuserをつくる user = %User{} |> User.changeset(params) |> Repo.insert! # build_assocで、%Post{user_id: user.id} が得られる build_assoc(user, :posts) |> Post.changeset(%{title: "今日の日記", body: "とても色々なことがありました。"}) |> Repo.insert! build_assoc(user, :posts) |> Post.changeset(%{title: "昨日の日記", body: "晴れました。"}) |> Repo.insert! # userに関連のあるPostを取得 posts = assoc(user, :posts) |> Repo.all # userを取得するときにpostsも取得 user = Repo.first!(from u in User, where: u.name == "Joe_noh", preload: :posts)
特に大きく変わったものはなさそう。
ちょっと話は逸れますが、validate_required
を使うとパラメータを必須にできますが、以前はcast/4
の引数でそういったものを指定していました。cast/4
のときは、そもそもキーが含まれてないとか、値がnil
のものを空だと判定していたと記憶しているのですが、validate_required
はnil
だけでなく、空文字列(""
)やスペースのみの文字列(" "
)も空とみなすようです。ActiveRecordのvalidates_presence_of
に寄せたんですかね。
many_to_many
いよいよ本題。Post
とTag
にmany_to_many
の関係を作ります。まずはtags
テーブル。
defmodule PlayEcto.Repo.Migrations.AddTagsTable do use Ecto.Migration def change do create table(:tags) do add :name, :string, null: false timestamps end end end
次に中間テーブルを用意します。ドキュメントに従い、外部キー以外のカラムは作らないようにしました。
すぐ下にjoin_through: "posts_tags"
という、テーブル名を直接文字列で指定するコードが出てきますが、ここには別途PostTag
モジュールを定義して、join_through: PostTag
と書くこともできます。なにが違うかというと、文字列で指定するとectoはtimestampなどの自動生成をしないのでtimestamps
とか書いても無駄なのです。純粋にpost_id
とtag_id
を結び付けたいだけなら文字列指定が良いんですかね。一方モジュール指定は、中間テーブルとしてだけでなく他のカラム(例えばtimestamp)も含めたいときなんかにやるんでしょうか。この場合は、ectoがtimestampなどを自動生成してくれます。
defmodule PlayEcto.Repo.Migrations.AddPostsTagsTable do use Ecto.Migration def change do create table(:posts_tags, primary_key: false) do add :post_id, references(:posts) add :tag_id, references(:tags) end create unique_index(:posts_tags, [:post_id, :tag_id]) end end
そしてTag
モジュールです。
defmodule PlayEcto.Tag do use Ecto.Schema import Ecto.Changeset alias PlayEcto.Post schema "tags" do field :name, :string many_to_many :posts, Post, join_through: "posts_tags" # ← これ大事 timestamps end @allowed ~w[name] def changeset(model, params) do model |> cast(params, @allowed) |> validate_required(:name) end end
Post
にも同様の1行を足します。
defmodule PlayEcto.Post do ... schema "posts" do ... belongs_to :user, User many_to_many :tags, Tag, join_through: "posts_tags" # 追加 timestamps end ... end
これで多対多の関連が定義できました。試してみましょう。
# Userつくる user = %User{} |> User.changeset(user_params) |> Repo.insert! # ユーザつくる # Postつくる post1 = build_assoc(user, :posts) |> Post.changeset(%{title: "PhoenixとEctoでAPIサーバ", body: "Phoenixはいいぞ"}) |> Repo.insert! post2 = build_assoc(user, :posts) |> Post.changeset(%{title: "Ectoを試してみる", body: "Ectoはいいぞ"}) |> Repo.insert! # Tagつくる tag1 = %Tag{} |> Tag.changeset(%{name: "Phoenix"}) |> Repo.insert! tag2 = %Tag{} |> Tag.changeset(%{name: "Ecto"}) |> Repo.insert! # PostにTagを関連付ける post1 |> Repo.preload(:tags) |> Post.changeset(%{}) |> put_assoc(:tags, [tag1, tag2]) |> Repo.update! post2 |> Repo.preload(:tags) |> Post.changeset(%{}) |> put_assoc(:tags, [tag2]) |> Repo.update!
できた。わいわい。
最後の2行のように好き勝手にput_assoc
などEcto.Changeset
の関数を呼んでしまうのはちょっとお行儀が悪い気がしていて、実際には例えばPost.add_tags/2
とか関数をつくっておくのが良いなと思いました。
ということで今回はこのへんで。