スポンサーリンク

2015年8月10日

[Elixir+Phoenix]Relationship Model

Goal

ユーザのフォロー機能を実装する。

Dev-Environment

OS: Windows8.1
Erlang: Eshell V6.4, OTP-Version 17.5
Elixir: v1.0.4
Phoenix Framework: v0.13.1
PostgreSQL: postgres (PostgreSQL) 9.4.4

Wait a minute

ユーザのフォロー機能を実装します。
以前の記事に少し追加をする形になります。
内容が重複してしまう部分がありますが、ご勘弁下さい。

Index

RelationshipModel
|> Create relationship model
|> User/Relationship of association
|> Validation
|> Utility Methods
|> Extra

Create relationship model

まずは、中間テーブルとして機能させる、Relationshipモデルを作成していきます。
データモデルは以下のようになります。
  • 中間テーブルのデータモデル
    • モデル: Relationship
    • テーブル: relationships
    • 生成カラム: follower_id:integer, followed_id:integer
    • 自動カラム: id:integer, inserted_at:timestamp, updated_at:timestamp
    • インデックス: follower_id, followed_id, follower_idとfollowed_idでの複合インデックス(ユニーク)
自動生成のコマンドで生成。
>mix phoenix.gen.model Relationship relationships follower_id:integer followed_id:integer
ファイル: priv/repo/[timestamp]_create_relationship.exs
マイグレーションファイルの編集。
defmodule SampleApp.Repo.Migrations.CreateRelationship do
  use Ecto.Migration
  @disable_ddl_transaction true

  def change do
    create table(:relationships) do
      add :follower_id, :integer
      add :followed_id, :integer

      timestamps
    end

    create index(:relationships, [:follower_id], concurrently: true)
    create index(:relationships, [:followed_id], concurrently: true)
    create index(:relationships, [:follower_id, :followed_id], unique: true, concurrently: true)
  end
end
Description:
さて、複合インデックスにユニークを指定している理由ですが、
フォローしているのに、またフォローができたらおかしなことになりますね。
それを防止するために、ユニークを指定しています。
マイグレーションの実行。
>mix ecto.migrate

User/Relationship of association

User/Relationshipモデルの関連付けを行います。
ファイル: web/models/user.ex
Userのschemaを以下のように編集する。
schema "users" do
  field :name, :string
  field :email, :string
  field :password_digest, :string
  field :password, :string, virtual: true

  has_many :microposts, SampleApp.Micropost

  # User who follow
  has_many :followed_users, SampleApp.Relationship, foreign_key: :follower_id
  has_many :relationships, through: [:followed_users, :followed_user]

  # Followers the user
  has_many :followers, SampleApp.Relationship, foreign_key: :followed_id
  has_many :reverse_relationships, through: [:followers, :follower]

  timestamps
end
Description:
逆転していて分かり辛くなっていると思うので、説明を書いておきます。
follower_id: フォローしているユーザ自身のid
followed_id: フォローしている相手ユーザのid
follower_idを検索キーにして、自分自身”が”フォローしているユーザのidを取得している。
has_many :followed_users, SampleApp.Relationship, foreign_key: :follower_id
has_many :relationships, through: [:followed_users, :followed_user]
followed_idを検索キーにして、自分自身”を”フォローしているユーザのidを取得している。
(followed_idに自分自身のidを指定すれば、自分自身をフォローしているユーザが取得できる)
has_many :followers, SampleApp.Relationship, foreign_key: :followed_id
has_many :reverse_relationships, through: [:followers, :follower]
ファイル: web/models/relationship.ex
Relationshipのschemaを以下のように編集する。
schema "relationships" do
  belongs_to :followed_user, SampleApp.User, foreign_key: :follower_id
  belongs_to :follower, SampleApp.User, foreign_key: :followed_id

  timestamps
end

Validation

changesetでバリデーションを行いたいと思います。
実施するバリデーションは存在性(presence)です。
確か同じことをUserモデルでもやっていましたね。
同じ機能の関数を定義はしたくありません。
なので、補助用のヘルパーモジュールでも用意するとしましょう。
ファイル: lib/helpers/validate_helper.ex
モジュールを新しく定義。
defmodule SampleApp.Helpers.ValidateHelper do
  # my presence check validation
  def validate_presence(changeset, field_name) do
    field_data = Ecto.Changeset.get_field(changeset, field_name)

    cond do
      field_data == nil ->
        add_error changeset, field_name, "#{field_name} is nil"
      field_data == "" ->
        add_error changeset, field_name, "No #{field_name}"
      true ->
        changeset
    end
  end
end
ファイル: web/web.ex
全モデルで使えるようにweb.exのmodelにimportを追加。
def model do
  quote do
    use Ecto.Model

    # My validate helper
    import SampleApp.Helpers.ValidateHelper
  end
end
ファイル: web/models/relationship.ex
chagesetにバリデーションを追加。
def changeset(model, params \\ :empty) do
  model
  |> cast(params, @required_fields, @optional_fields)
  |> validate_presence(:followed_user)
  |> validate_presence(:follower)
end
ファイル: web/models/user.ex
User.validate_presence/2は削除して下さい。

Utility Methods

フォローしたり、フォローを解除するための補助関数を用意します。
ファイル: web/models/relationship.ex
以下の関数を追加。
フォローする。
def follow!(signed_id, follow_user_id) do
  changeset = SampleApp.Relationship.changeset(
    %SampleApp.Relationship{}, %{follower_id: signed_id, followed_id: follow_user_id})

  if changeset.valid? do
    SampleApp.Repo.insert!(changeset)
    true
  else
    false
  end
end
フォローしているか確認する。
def following?(signed_id, follow_user_id) do
  relationship = SampleApp.Repo.all(
    from(r in SampleApp.Relationship,
      where: r.follower_id == ^signed_id and r.followed_id == ^follow_user_id))

  !Enum.empty?(relationship)
end
フォローを解除する。
def unfollow!(signed_id, follow_user_id) do
  [relationship] = SampleApp.Repo.all(
    from(r in SampleApp.Relationship,
      where: r.follower_id == ^signed_id and r.followed_id == ^follow_user_id, limit: 1))

  SampleApp.Repo.delete!(relationship)
end

Extra

ちょっと嵌まったので書いておく・・・
以下の形で取得すると・・・
relationship = SampleApp.Repo.all(
    from(r in SampleApp.Relationship,
      where: r.follower_id == ^signed_id and r.followed_id == ^follow_user_id, limit: 1))
以下の結果が得られる。
[%SampleApp.Relationship{__meta__: %Ecto.Schema.Metadata{source: {nil,
   "relationships"}, state: :loaded}, followed_id: 2,
 followed_user: #Ecto.Association.NotLoaded<association :followed_user is not loaded>,
 follower: #Ecto.Association.NotLoaded<association :follower is not loaded>,
 follower_id: 1, id: 3, inserted_at: #Ecto.DateTime<2015-08-10T09:09:17Z>,
 updated_at: #Ecto.DateTime<2015-08-10T09:09:17Z>}]
しかし、上記の出力結果をRepo.delete!/2へ渡すとエラーになる。
Repo.delete!/2が求めているのは、以下のような内容。
(になっていない)
%SampleApp.Relationship{__meta__: %Ecto.Schema.Metadata{source: {nil,
   "relationships"}, state: :loaded}, followed_id: 2,
 followed_user: #Ecto.Association.NotLoaded<association :followed_user is not loaded>,
 follower: #Ecto.Association.NotLoaded<association :follower is not loaded>,
 follower_id: 1, id: 3, inserted_at: #Ecto.DateTime<2015-08-10T09:09:17Z>,
 updated_at: #Ecto.DateTime<2015-08-10T09:09:17Z>}
そのため、以下のような形で取得を行っている。
[relationship] = SampleApp.Repo.all(
    from(r in SampleApp.Relationship,
      where: r.follower_id == ^signed_id and r.followed_id == ^follow_user_id, limit: 1))
覚えておいた方が良いのは、
Repo.all/2で取得すると一件でもリストになると言うこと。
そして、それでは受け付けてもらえない関数があると言うこと。
一瞬、Repo.delete_all/2を使うことも考えたが・・・
複数件の削除でないことと、!(感嘆符)がないので処理に失敗した場合、
例外を投げるか分からなかったため使用はしなかった。
(なくても、あからさまおかしければ、例外かエラーは発生するが・・・)
!(感嘆符)がある関数は、常に正常に動作する必要がある。
insert!やdelete!など末尾に!(感嘆符)のある関数は、
処理に失敗した場合には例外を発生させなければならない。

Speaking to oneself

Relationshipの補助関数は後で変更する可能性がありますね。
validate_presence/1があるモジュールだが、適切な名前と作成位置だったのか自信がない。
Ectoに作ってくれないですかね?このバリデーション。
でも待てよ・・・実は不要だから作ってないのでは?
そうすると同等の機能を持っている何かがあるのかもしれない。
う~む、調べないと分からないな・・・
そも、@required_fields に指定していれば、
値がない場合、エラーを出してくれるから、
そちらで対応しているのかもしれない。

Bibliography

人気の投稿