PostgreSQL
Web
Elixir
ecto
Poenix

Elixir Ecto のまとめ

 Phoenixをやろうとして、いろんなドキュメントを読んでいるのですが、Ecto関連が初心者の私には複雑かなと思えたので、まじめにGetting Startedから始めてみました。EctoはElixirのdatabase wrapperでquery generatorです。異なるDB(PostgreSQLやMySQLなど)のインターフェースを標準化してくれます。デフォルトはPostgreSQLです。
https://hexdocs.pm/ecto/getting-started.html

 Ectoは大きく4つの構成要素(Ecto.Repo、Ecto.Schema、Ecto.Changeset、Ecto.Query)から成ります。 この枠組みを押さえておかないと、いろいろなドキュメントを読む時に、Ectoの説明がまとまりがないと感じてしまうことがあります。ここでは以下に順を追って説明していきます。
https://hexdocs.pm/ecto/Ecto.html

 また今回の実験の環境は下記の記事にある手順で構築しました。
CentOS7にErlangとElixir、Phoenixをインストールしてみる - Qiita

(2018/04/30)追記 - 本記事の応用記事を書きました。
Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
Elmとleafletでつくるgeolocationの再現機 - Qiita

1.Ectoのインストール

 まずmixでfriendsというプロジェクトを作成します。オプションの --sup はこのアプリケーションがsupervision treeを持っていることを示しています。

mix new friends --sup

 次に必要なパッケージをインストールします。以下のファイルのdepsを変更します。postgrexはPostgreSQLのドライバです。

mix.exs
defp deps do
  [{:ecto, "~> 2.0"},
   {:postgrex, "~> 0.11"}]
end

 インストールします。

mix deps.get

 これでEctoは以下の経路でPostgreSQLにアクセスできるようになります。

Ecto --> Ecto.Adapters.Postgres --> postgrex --> PostgreSQL.

2.Ecto.Repo

 Repo(Repositories)はdata storeのラッパーで、データストアに対するAPIを提供します。データベースにアクセスするときはもちろん、開発の初期段階でモックデータにアクセスするときもこのAPIを利用します。EctoではRepository を通して create, update, destroy や query を発行することができます。 ですからRepository は、データベースとコミュニケーションするための adapter や credentialsを知る必要があります。それらの設定方法を以下に示します。

 まず以下のコマンド一発でconfig/config.exsに必要な項目を追加し、且つlib/friends/repo.exファイルを作成します。

mix ecto.gen.repo -r Friends.Repo

 まずconfig/config.exsをご確認ください。以下のような項目が追加されているはずです。私は更に自分の環境に合わせてusername/passwordを手で書き換えました。これはPostgreSQLに接続するために必要な情報を含んでいます。

config/config.exs
config :friends, Friends.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "friends_repo",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

 同時にlib/friends/repo.exというファイルも作成されているので確認してください。Friends.Repoはデータベースへのqueryに使われるます。ここではEcto.Repoを使うことが宣言されています。otp_appはどのアプリがデータベースへアクセスしているかを教えています。:friendsとなっているのでこのプロジェクトのconfig.exsを参照します。その接続情報をもとにデータベースにアクセスします。

lib/friends/repo.ex(Ectoバージョン)
defmodule Friends.Repo do
  use Ecto.Repo, otp_app: :friends
end

 ちなみに開発の初期段階でデータベース(Ecto)を使わずに、モックデータを使う場合はここの定義を以下のように変えます。つまりここでFriends.Repo.all や Friends.Repo.get、Friends.Repo.get_byなどのAPIを実装します。こうしておけば本番のデータベース利用時に他の個所の変更はなくスムーズに移行できます。(厳密にいえばEctoを使わない場合は、以下のlib/friends/application.exの編集は必要ありません。)

lib/friends/repo.ex(モックバージョン)
defmodule Friends.Repo do
  def all(Friends.Person ) do
   [%Friends.Person{id: "1", first_name: "José", last_name: "Valim", age: 18},
    %Friends.Person{id: "2", first_name: "Bruce", last_name: "Rapids", age: 20},
    %Friends.Person{id: "3", first_name: "Chris", last_name: "Mccord", age: 19}]
  end
  def all(_module), do: []

  def get(module, id) do
   Enum.find all(module), fn map -> map.id == id end
  end
  def get_by(module, params) do
   Enum.find all(module), fn map -> 
   Enum.all?(params, fn {key, val} -> Map.get(map, key) == val end)
  end
end

 次にlib/friends/application.exを編集します。Friends.Repo をapplication の supervision treeの中のsupervisor としてセットします。これによって、Ecto process を起動して、アプリのqueryを受け取り実行できるようになります。

lib/friends/application.ex
def start(_type, _args) do
  import Supervisor.Spec

  children = [
    Friends.Repo,
  ]

 最後にconfig/config.exsを編集して、以下の一行を追加します。これでアプリケーションにrepoについて教えます。

config/config.exs
config :friends, ecto_repos: [Friends.Repo]

3.DBとテーブルの作成

 以上で準備は終わっているのでDBを作成します。以下のコマンドを打ちます。

mix ecto.create

 次にテーブルを作成するための準備をします。まず以下のmixコマンドを打ちます。

 mix ecto.gen.migration create_people

 そうすると以下のようにファイルを作成した旨のメッセージが表示されます。

[root@www13134uf friends]# mix ecto.gen.migration create_people
* creating priv/repo/migrations
* creating priv/repo/migrations/20180111043316_create_people.exs```

 ファイル 20180111043316_create_people.exs を編集して以下のようにします。

priv/repo/migrations/20180111043316_create_people.exs
defmodule Friends.Repo.Migrations.CreatePeople do
  use Ecto.Migration

  def change do
    create table(:people) do
      add :first_name, :string
      add :last_name, :string
      add :age, :integer
    end
  end
end

 最後に上のmigrationファイルを走らせて、テーブルを作成します。

mix ecto.migrate

 もしこの時migrationに失敗したら、mix ecto.rollbackでこのchangeをundoできます。

4.Ecto.Schema

 Schemas はDB テーブルを Elixir struct に map するために使います。

 Friends.Person schemaは以下のような構文で作られ、Elixir structに落とし込まれます。schemaはデフォルトでidというintegrのfieldが自動追加されます。field macro はname と type で field を定義します。

lib/friends/person.ex
defmodule Friends.Person do
  use Ecto.Schema

  schema "people" do
    field :first_name, :string
    field :last_name, :string
    field :age, :integer
  end
end

 schemaファイルを作ったらiex -S mixでElixir shellを立ち上げ、schemaを使ってみます。

[root@www13134uf friends]# iex -S mix
Erlang/OTP 20 [erts-9.1] [source] [64-bit] ...

Interactive Elixir (1.7.0-dev) - press Ctrl+C to exit (type h() ENTER for help)

 Friends.Person structでpersonを作ります。

iex(1)> person = %Friends.Person{age: 28}
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:built, "people">,
  age: 28,
  first_name: nil,
  id: nil,
  last_name: nil
}

 上で作ったpersonのageにアクセスします。

iex(2)> person.age # => 28
28

 Friends.Person structで空のpersonを作り直します。

iex(3)> person = %Friends.Person{}
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:built, "people">,
  age: nil,
  first_name: nil,
  id: nil,
  last_name: nil
}

 上で作ったpersonをDBにinsertします。結果としてidの欄が返されるのが確認できます。

iex(4)> Friends.Repo.insert(person)

15:20:43.992 [debug] QUERY OK db=3.0ms
INSERT INTO "people" VALUES (DEFAULT) RETURNING "id" []
{:ok,
 %Friends.Person{
   __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
   age: nil,
   first_name: nil,
   id: 3,
   last_name: nil
 }}

5.Ecto.Changeset

 Changesets は外部データであるparametersをもとにデータベースに変更を加える時に用いられる変更部分(params )を表すデータ構造で、変更部分(params )に含まれるfield値を filter したり castしたりする方法を提供します。同時にその変更部分(params )がDBに適用される前に、track や validateを行います。

https://hexdocs.pm/ecto/Ecto.html
https://hexdocs.pm/ecto/Ecto.Changeset.html

※changesetの意味をつかむためには、「6.Ecto.Query」の最後の例であるRepo.update(changeset)の使い方を見るとわかりやすいです。

 それでは実験してみましょう。まずlib/friends/person.exにchangesetの定義を追加します。

lib/friends/person.ex
def changeset(person, params \\ %{}) do
  person
  |> Ecto.Changeset.cast(params, [:first_name, :last_name, :age])
  |> Ecto.Changeset.validate_required([:first_name, :last_name])
end

ここでcastがchangesetを作ってくれています。dataに対してparamsが変更部分を表しています。

cast
cast(data, params, permitted, opts \\ [])

 さてpersonを作り、それをもとにchangesetをつくり、最後にそれをinsertします。insertは前回のようにschema structの personを直接引数としてinsert(person)のようにも使えますし、今回のようにinsert(changeset)のようにも使えます。changesetの方がcastやvalidationを行ってくれるので良いですね。ちなみにvalidationは変更されるフィールド(parasに含まれるもの)に関するもののみが行われます。

iex(1)> person = %Friends.Person{}
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:built, "people">,
  age: nil,
  first_name: nil,
  id: nil,
  last_name: nil
}

iex(2)> changeset = Friends.Person.changeset(person, %{first_name: "Ryan", last_
name: "Bigg"})
#Ecto.Changeset<
  action: nil,
  changes: %{first_name: "Ryan", last_name: "Bigg"},
  errors: [],
  data: #Friends.Person<>,
  valid?: true
>

iex(3)> Friends.Repo.insert(changeset)

16:55:17.455 [debug] QUERY OK db=2.8ms
INSERT INTO "people" ("first_name","last_name") VALUES ($1,$2) RETURNING "id" ["Ryan", "Bigg"]
{:ok,
 %Friends.Person{
   __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
   age: nil,
   first_name: "Ryan",
   id: 4,
   last_name: "Bigg"
 }}

 次は上と同じ手順ですが、validationエラーになる例です。changesetを作る段階でchangeset.valid?がfalseでエラーになっています。当然次のinsertもエラーになります。しかしchangeset.valid?がtrueであってもinsertがエラーになることはあります。通常は、最終的にデータベースにinsertを行い、エラーが返ってくるかを判断しなければなりません。

iex(10)> person = %Friends.Person{}
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:built, "people">,
  age: nil,
  first_name: nil,
  id: nil,
  last_name: nil
}

iex(11)> changeset = Friends.Person.changeset(person, %{})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    first_name: {"can't be blank", [validation: :required]},
    last_name: {"can't be blank", [validation: :required]}
  ],
  data: #Friends.Person<>,
  valid?: false
>

iex(12)> Friends.Repo.insert(changeset)
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{},
   errors: [
     first_name: {"can't be blank", [validation: :required]},
     last_name: {"can't be blank", [validation: :required]}
   ],
   data: #Friends.Person<>,
   valid?: false
 >}

 cast(data, params, permitted, opts \ [])のpermittedの意味をはっきりさせるためにchangesetの定義を以下のように変えます。changesetにおいてはpermittedにある項目だけに注目して、changes(差分)が作られます。paramsに含まれていてもpermittedになければchanges(差分)には含まれません。

lib/friends/person.ex
def changeset(person, params \\ %{}) do
  person
  # |> Ecto.Changeset.cast(params, [:first_name, :last_name, :age])
  |> Ecto.Changeset.cast(params, [:first_name, :age])
  |> Ecto.Changeset.validate_required([:first_name, :last_name])
end

 permittedからlast_nameを外すと、以下のようにchanges(差分)からもlast_nameが外れてしまいます。

iex(1)> person = %Friends.Person{}
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:built, "people">,
  age: nil,
  first_name: nil,
  id: nil,
  last_name: nil
}
iex(2)> changeset = Friends.Person.changeset(person, %{first_name: "Ryan", last_name: "Bigg"})
#Ecto.Changeset<
  action: nil,
  changes: %{first_name: "Ryan"},
  errors: [last_name: {"can't be blank", [validation: :required]}],
  data: #Friends.Person<>,
  valid?: false
>

6.Ecto.Query

 データベースへの問い合わせは、まずQueryを構築してから、次にEcto.RepoにQueryを渡してデータベースへの問い合わせを実行する、というステップを踏みます。

 まずデーターベースを作り直し、shellを立ち上げます。

mix ecto.drop
mix ecto.create
mix ecto.migrate

iex -S mix

 データベースにサンプルデータをinsertします。

iex(1)> people = [
...(1)>   %Friends.Person{first_name: "Ryan", last_name: "Bigg", age: 28},
...(1)>   %Friends.Person{first_name: "John", last_name: "Smith", age: 27},
...(1)>   %Friends.Person{first_name: "Jane", last_name: "Smith", age: 26},
...(1)> ]

Enum.each(people, fn (person) -> Friends.Repo.insert(person) end)

 以下は1個の結果をfetchする問い合わせで、それぞれ最初の結果と最後の結果を返します。

iex(18)> Friends.Person |> Ecto.Query.first |> Friends.Repo.one

14:12:53.333 [debug] QUERY OK source="people" db=2.8ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 ORDER BY p0."id" LIMIT 1 []
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
  age: 28,
  first_name: "Ryan",
  id: 1,
  last_name: "Bigg"
}


iex(19)> Friends.Person |> Ecto.Query.last |> Friends.Repo.one

14:13:09.237 [debug] QUERY OK source="people" db=2.8ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 ORDER BY p0."id" DESC LIMIT 1 []
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
  age: 26,
  first_name: "Jane",
  id: 3,
  last_name: "Smith"
}

 以下は全ての結果をfetchする問い合わせです。

iex(20)> Friends.Person |> Friends.Repo.all

14:15:42.476 [debug] QUERY OK source="people" db=1.8ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 []
[
  %Friends.Person{
    __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
    age: 28,
    first_name: "Ryan",
    id: 1,
    last_name: "Bigg"
  },
  %Friends.Person{
    __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
    age: 27,
    first_name: "John",
    id: 2,
    last_name: "Smith"
  },
  %Friends.Person{
    __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
    age: 26,
    first_name: "Jane",
    id: 3,
    last_name: "Smith"
  }
]

 以下はidによる問い合わせです。

iex(24)> Friends.Person |> Friends.Repo.get(2)

14:17:39.662 [debug] QUERY OK source="people" db=1.6ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 WHERE (p0."id" = $1) [2]
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
  age: 27,
  first_name: "John",
  id: 2,
  last_name: "Smith"
}

 属性の値を指定した問い合わせです。

iex(25)>  Friends.Person |> Friends.Repo.get_by(first_name: "Ryan")

14:21:02.842 [debug] QUERY OK source="people" db=2.8ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 WHERE (p0."first_name" = $1) ["Ryan"]
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
  age: 28,
  first_name: "Ryan",
  id: 1,
  last_name: "Bigg"
}

 最後にデータベースのupdateの例です。現在のpersonをfetchして、更新内容をchangesetにまとめます。最後にRepo.update(changeset)でデータベースに反映させます。

iex(31)> person = Friends.Person |> Ecto.Query.first |> Friends.Repo.one

14:23:16.025 [debug] QUERY OK source="people" db=0.5ms
SELECT p0."id", p0."first_name", p0."last_name", p0."age" FROM "people" AS p0 ORDER BY p0."id" LIMIT 1 []
%Friends.Person{
  __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
  age: 28,
  first_name: "Ryan",
  id: 1,
  last_name: "Bigg"
}

iex(32)> changeset = Friends.Person.changeset(person, %{age: 29})
#Ecto.Changeset<
  action: nil,
  changes: %{age: 29},
  errors: [],
  data: #Friends.Person<>,
  valid?: true
>

iex(33)> Friends.Repo.update(changeset)

14:23:38.647 [debug] QUERY OK db=2.1ms
UPDATE "people" SET "age" = $1 WHERE "id" = $2 [29, 1]
{:ok,
 %Friends.Person{
   __meta__: #Ecto.Schema.Metadata<:loaded, "people">,
   age: 29,
   first_name: "Ryan",
   id: 1,
   last_name: "Bigg"
 }}