読者です 読者をやめる 読者になる 読者になる

mmag

ハマったことメモなど

Ecto 2.0.0-betaを試す (2)

第2弾です。

今回はリレーションです。以前からあるhas_one, has_manyを復習し、新たに入ったmany_to_manyを試してみます。

TL;DR

これ書いてる時点のコードはこちら。

github.com

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_requirednilだけでなく、空文字列("")やスペースのみの文字列(" ")も空とみなすようです。ActiveRecordvalidates_presence_ofに寄せたんですかね。

many_to_many

いよいよ本題。PostTagmany_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_idtag_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とか関数をつくっておくのが良いなと思いました。

ということで今回はこのへんで。

テーマはFB matteをベースにしてます。作者さんに感謝を込めて。