技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第15章 Custom Errors)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
とりあえず主な項目はコレで最後です。
残るはテストの話とデプロイの話が残っていますが、残ってる話の方が重要そうな気がしますね。

今回はたぶん短めです。
PhoenixというかElixirの例外処理のお話に近いかも。

Custom Errors

Viewsの項目でErrorViewに関しては一回やりました。
基本的に400とか500のエラーはテンプレート(404.html.eexとか)を用意しておけばPhoenixのコントローラがよしなにしてくれます。
ビューのエラーはこのErrorViewで受けるとして、内部処理のエラーはPhoenixでは例外処理などが少々利用されているようです。

Custom Errors

Elixirの標準機能として、例外を定義するためのdefexceptionを利用してエラー処理を行っています。
エラーを実装したいモジュール内部にモジュールを更に定義し、内部でdefexceptionを定義するように実装するそうです。
router.exが利用しているPhoenix.Routerの内部実装が例としてドキュメントに記載されています。

defmodule Phoenix.Router do
  defmodule NoRouteError do
    @moduledoc """
    Exception raised when no route is found.
    """
    defexception plug_status: 404, message: "no route found", conn: nil, router: nil

    def exception(opts) do
      conn   = Keyword.fetch!(opts, :conn)
      router = Keyword.fetch!(opts, :router)
      path   = "/" <> Enum.join(conn.path_info, "/")

      %NoRouteError{message: "no route found for #{conn.method} #{path} (#{inspect router})",
      conn: conn, router: router}
    end
  end
...
end

Elixirの例外処理の記事では取り扱ってなかったのですが、defexceptionを宣言したモジュール内でdef exceptionとして関数を実装すると、エラー処理の関数になるようです。
(詳細はElixirのExceptionのビヘイビアを確認)

Plugを実装する際にはPlug.Exceptionというプロトコルがあるようなのでそちらを利用するのが良いかと思います。
例なども示されてますが、特殊ケースな気がするので、また利用機会があった際に追記したいと思います。

今回は短めですがこんなもんで。

Phoenix入門 (第14章 Mix Tasks その2)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
引き続きMix Tasksの項目を見ていきます。
今回はEcto関連のコマンドから。

Ecto Specific Mix Tasks

このEcto周辺のコマンドですが、当たり前ですが--no-ectoとかしてない場合が対象です。

mix ecto.create

データベースの作成を行うコマンドです。デフォルトならconfigやlib/hello/repo.exに記載したデータベースに作成するはずです。
デフォルトでいいなら引数は要らないのでシンプルです。

$ mix ecto.create
The database for Hello.Repo has been created.

別の名前で定義してたりするなら、-rオプションでモジュール名を指定します。

$ mix ecto.create -r OurCustom.Repo
The database for OurCustom.Repo has been created.

PostgreSQLのユーザー周りの説明は割愛します。
(要望があれば書きますが)

ecto.drop

純粋にecto.createの逆だと思えば良さそうで、指定したデータベースからデータを消去します。
使い方もほぼcreateと同じで、デフォルトなら引数は不要。

$ mix ecto.drop
The database for Hello.Repo has been dropped.

-rでモジュール名を指定することも可能。

$ mix ecto.drop -r OurCustom.Repo
The database for OurCustom.Repo has been dropped.

mix ecto.gen.repo

データストアが1個じゃないケースもあると思います。そういうときにこのecto.gen.repoで新しいリポジトリを作成できるそうです。
たとえば、デフォルトのリポジトリHello.Repoで、別のリポジトリOurCustom.Repoであるばあい、下記のようにして作成するようです。

$ mix ecto.gen.repo -r OurCustom.Repo
* creating lib/our_custom
* creating lib/our_custom/repo.ex
* updating config/config.exs
Don't forget to add your new repo to your supervision tree
(typically in lib/hello.ex):

worker(OurCustom.Repo, [])

* updating config/config.exsとあるので、configも修正されています。

...
config :hello, OurCustom.Repo,
database: "hello_repo",
username: "user",
password: "pass",
hostname: "localhost"
...

これは各環境で合わせて下さい。
場合によってはdev.exsprod.exsに記載してもいいかもしれません。

ファイルの生成などは自動でやってくれましたが、リポジトリのワーカーをSupervisorで管理する必要があります。
ドキュメントではlib/hello.exを編集、と書いてありますがたぶんlib/hello/application.exの間違いです。

...
children = [
  # Start the Ecto repository
  Hello.Repo,
  # Start the endpoint when the application starts
  HelloWeb.Endpoint,
  # Starts a worker by calling: Hello.Worker.start_link(arg)
  # {Hello.Worker, arg},
  # Here you could define other workers and supervisors as children
  OurCustom.Repo
]
...

これで大丈夫そうです。

mix ecto.gen.migration

マイグレーションする準備をするのがecto.gen.migrationです。こういうフレームワークにはよくある奴だと思います。
Contextの章でもいじったファイルを作成するやつで、マイグレーションも実際の所はElixirのスクリプトを実行して行っていますのでそのスクリプトを生成するコマンドということです。
基本的に何かのコマンドで自動生成されるのでそこまで出番があるように思えませんが、スキームの変更などをした場合には手動で作成する必要がありそうです。
コマンドにマイグレーションファイル名を引数として渡します。

mix ecto.gen.migration add_comments_table
* creating priv/repo/migrations
* creating priv/repo/migrations/20150318001628_add_comments_table.exs

マイグレーション用のファイルがpriv/repo/migrationsに作成されます。おそらくタイムスタンプ_指定した名前.exsというファイルが作成されます。
ファイルの中身は下記のようになっているそうです。

defmodule Hello.Repo.Migrations.AddCommentsTable do
  use Ecto.Migration

  def change do
  end
end

基本的にコマンドで生成した場合は特に何も書かれていないはずです。
このchange/0に記載された仕様をもとに、データベースを編集したりロールバックしたりします。
たとえば、commentsというテーブルを作成し、bodyword_countというフィールド+タイムスタンプがあるスキームを追加したとします。
このとき、マイグレーションファイルは下記のように編集します。

...
def change do
  create table(:comments) do
    add :body,       :string
    add :word_count, :integer
    timestamps()
  end
end
...

ちなみに、今はcreateを使いましたが、スキームを追加するのではなく変更する場合などはalterを利用します。

基本的な使い方は上記の通りですが、デフォルトではないリポジトリを対象としたマイグレーションのファイルは、ecto.createなどと同様-rオプションでリポジトリ名を指定してやる必要があります。

$ mix ecto.gen.migration -r OurCustom.Repo add_users
* creating priv/repo/migrations
* creating priv/repo/migrations/20150318172927_add_users.exs

mix ecto.migrate

マイグレーションファイルを作成したらすることは決まっています。マイグレーションです。
何も考えずにmix ecto.migrateすることが多いのではないでしょうか。

$ mix ecto.migrate
[info] == Running Hello.Repo.Migrations.AddCommentsTable.change/0 forward
[info] create table comments
[info] == Migrated in 0.1s

そこまで開発にかかわってこない可能性がありますが、mix ecto.migrateをするとデータベース上のschema_migrationsというテーブルにマイグレーション日時のタイムスタンプが作成されるようです。

hello_dev=# select * from schema_migrations;
version     |     inserted_at
----------------+---------------------
20150317170448 | 2015-03-17 21:07:26
20150318001628 | 2015-03-18 01:45:00
(2 rows)

あとで見てみますが、ecto.rollbackなどでロールバックするときはこの辺の情報を利用してロールバックするようです。

基本的に存在している全てのマイグレーションファイルを実行してマイグレートするコマンドですが、一応実行する個数を指定出来るようです(順番はタイムスタンプ依存?)
-n--stepのオプションを使用して実行するマイグレーションの個数を指定します。

$ mix ecto.migrate -n 2
[info] == Running Hello.Repo.Migrations.CreatePost.change/0 forward
[info] create table posts
[info] == Migrated in 0.0s
[info] == Running Hello.Repo.Migrations.AddCommentsTable.change/0 forward
[info] create table comments
[info] == Migrated in 0.0s

一応下記も同様

$ mix ecto.migrate --step 2

-vのオプションを使用すると、指定したタイムスタンプのマイグレーションファイルのみを実行できるようです。

$ mix ecto.migrate -v 20150317170448

--toも同じ働き。

$ mix ecto.migrate --to 20150317170448

mix ecto.rollback

ロールバック、詰まり先祖返りできます。スキーム変更したけどやり直したい!とかいう場合に活用できそうです。
それもコレもマイグレーションファイルにきっちり変更の仕様が記載されているおかげでしょう。

$ mix ecto.rollback
[info] == Running Hello.Repo.Migrations.AddCommentsTable.change/0 backward
[info] drop table comments
[info] == Migrated in 0.0s

何も指定しなければ全部戻ってしまいそうです。

rollbackmigrationと同様のオプションが取れます。例えば-nでn個マイグレーションファイルを遡る、などでしょうか。
こちらはオプションを大いに活用しそうです。

Creating Our Own Mix Tasks

おおよそ見ていったmixコマンドで事足りそうですが、「もうちょっとやってくれよ」「ここ毎回同じ事してる」という場合はかゆいところに手が届くmixコマンドが欲しくなると思います。
そういった需要に応えるがごとく、mixコマンドを作成できるそうです。

まずはlib/フォルダにmix/tasksディレクトリを作成していきます。

$ mkdir -p lib/mix/tasks

試しにhello.greeting.exというファイルを作成するとします。

defmodule Mix.Tasks.Hello.Greeting do
  use Mix.Task

  @shortdoc "Sends a greeting to us from Hello Phoenix"

  @moduledoc """
    This is where we would put any long form documentation or doctests.
  """

  def run(_args) do
    Mix.shell.info("Greetings from the Hello Phoenix Application!")
  end

  # We can define other functions as needed here.
end

慣習なのか知りませんが、基本的にElixirはディレクトリ名をモジュール名に適応するようです。
(そもそもコンパイル時にチェックされる?すみません、この辺は勉強不足です。)
なので、lib/ディレクトリ以下のmix/tasksディレクトリ下にあるhello.greeting.exなので、モジュール名はMix.Tasks.Hello.Greetingになります。

お次にuse Mix.Taskが宣言されています。たぶんこれでmixのコマンドとして機能するのでしょう。

@shortdocのモジュール属性ですが、これはmix help字に表示される説明文です。

@moduledocはモジュールのドキュメントです。コレに関しては割愛します。

run/1mixで呼ばれたときに実行される関数です。ここではmixで実行されたときにシェルに文字列がプリントされるだけのようです。

以上の仕様で、利用する為にはまずコンパイルします。

$ mix compile
Compiled lib/tasks/hello.greeting.ex
Generated hello.app

コンパイルすると、mix helpにも表示されるようです。

$ mix help | grep hello
mix hello.greeting # Sends a greeting to us from Hello Phoenix

実際に利用する場合は、ファイル名で実行するようです。

$ mix hello.greeting
Greetings from the Hello Phoenix Application!

run/1では今のところ文字列を出力するMix.shell.info/1しか利用していませんが、アプリケーションを実行するにはMix.Task.run/1を利用すればいいようです。

...
  def run(_args) do
    Mix.Task.run("app.start")
    Mix.shell.info("Now I have access to Repo and other goodies!")
  end
...

もうちょっといろんな機能を実装するにはMixの仕様をより深掘りする必要がありそうです・・・

Phoenix入門 (第14章 Mix Tasks その1)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
今回はMix Tasksということでmix phx.hogehogeのコマンドあたりの説明でしょうか。

Mix Tasks

Phoenix Specific Mix Tasks

Phoenixで使いそうなコマンド一覧があります。

mix local.phx          # Updates the Phoenix project generator locally
mix phx                # Prints Phoenix help information
mix phx.digest         # Digests and compresses static files
mix phx.digest.clean   # Removes old versions of static assets.
mix phx.gen.cert       # Generates a self-signed certificate for HTTPS testing
mix phx.gen.channel    # Generates a Phoenix channel
mix phx.gen.context    # Generates a context with functions around an Ecto schema
mix phx.gen.embedded   # Generates an embedded Ecto schema file
mix phx.gen.html       # Generates controller, views, and context for an HTML resource
mix phx.gen.json       # Generates controller, views, and context for a JSON resource
mix phx.gen.presence   # Generates a Presence tracker
mix phx.gen.schema     # Generates an Ecto schema and migration file
mix phx.gen.secret     # Generates a secret
mix phx.new            # Creates a new Phoenix application
mix phx.new.ecto       # Creates a new Ecto project within an umbrella project
mix phx.new.web        # Creates a new Phoenix web project within an umbrella project
mix phx.routes         # Prints all routes
mix phx.server         # Starts applications and their servers

細かい機能はPhoenix公式ドキュメントの「MIX TASKS」を見れば良さそうです。
幾つか簡単に取り上げてくれているので、一応目を通しておきます。

mix phx.new

言わずと知れたPhoenixプロジェクトを生成する際に使うコマンドです。
--no-ecto--no-webpackというオプションをつける事でEctoの実装を無くしたり、Webpackの実装を無くしたりできるそうですが、基本的には必要なんじゃないでしょうか。
APIサーバーとして機能するときはWebpackは不要?)

実際に生成するプロジェクト名はスネークケースがいいっぽいです。

> mix phx.new task_tester

相対パスでも絶対パスでもヨシ。

> mix phx.new ../task_tester
> mix phx.new /Users/me/work/task_tester

mix phx.newでプロジェクトを生成するとアプリケーション名はプロジェクト名(上記だとtask_tester)になります。
もしアプリケーション名を変更したい場合はmix phx.new task_tester --app hello--appオプションを利用するそうです。

mix.exsでアプリケーション名が変更されています。

defmodule Hello.MixProject do
  use Mix.Project

  def project do
    [app: :hello,
     version: "0.1.0",
...

このアプリケーション名を変更するとモジュール名などの接頭辞が変わるようです。

defmodule HelloWeb.PageController do
  use HelloWeb, :controller
...

--appオプションでhelloを設定したのでモジュール名がHelloから始まってます。
lib/フォルダ以下のディレクトリやファイル名なども変わります。

基本は--appでいいような気はしますが、「モジュール名の接頭辞だけ変えたい!」という場合には--moduleオプションで指定出来るようです。

$  mix phx.new task_tester --module Hello
* creating task_tester/config/config.exs
* creating task_tester/config/dev.exs
* creating task_tester/config/prod.exs
* creating task_tester/config/prod.secret.exs
* creating task_tester/config/test.exs
* creating task_tester/lib/task_tester/application.ex
* creating task_tester/lib/task_tester.ex
* creating task_tester/lib/task_tester_web/channels/user_socket.ex
* creating task_tester/lib/task_tester_web/views/error_helpers.ex
* creating task_tester/lib/task_tester_web/views/error_view.ex
* creating task_tester/lib/task_tester_web/endpoint.ex
* creating task_tester/lib/task_tester_web/router.ex
* creating task_tester/lib/task_tester_web.ex
* creating task_tester/mix.exs
* creating task_tester/README.md
* creating task_tester/.gitignore
* creating task_tester/test/support/channel_case.ex
* creating task_tester/test/support/conn_case.ex
* creating task_tester/test/test_helper.exs
* creating task_tester/test/task_tester_web/views/error_view_test.exs
* creating task_tester/lib/task_tester_web/gettext.ex
* creating task_tester/priv/gettext/en/LC_MESSAGES/errors.po
* creating task_tester/priv/gettext/errors.pot
* creating task_tester/lib/task_tester/repo.ex

mix.exsは下のようなかんじ。

defmodule Hello.MixProject do
  use Mix.Project

  def project do
    [app: :task_tester,
...

アプリケーション名はtask_testerのままで、接頭辞のみがHelloとなっています。
ややこしいことになりそうなので、変えたいなら--appを使った方が良さそうです。

mix phx.gen.html

これはContextあたりで使いました。HTMLでのresourcerouter.exresource指定する奴)を生成する際に便利なコマンドです。
Webレイヤーの実装(コントローラ、ビュー、テンプレート)はもちろん、EctoのマイグレーションファイルやContextも生成してくれます。

このコマンドは引数が多くてややこしいですが、その中身は至ってシンプルで
mix phx.gen.html Context名 スキーマ名 リソース名 スキーマのリスト(項目名:型)
です。

$ mix phx.gen.html Blog Post posts body:string word_count:integer
* creating lib/hello_web/controllers/post_controller.ex
* creating lib/hello_web/templates/post/edit.html.eex
* creating lib/hello_web/templates/post/form.html.eex
* creating lib/hello_web/templates/post/index.html.eex
* creating lib/hello_web/templates/post/new.html.eex
* creating lib/hello_web/templates/post/show.html.eex
* creating lib/hello_web/views/post_view.ex
* creating test/hello_web/controllers/post_controller_test.exs
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20170906150129_create_posts.exs
* creating lib/hello/blog/blog.ex
* injecting lib/hello/blog/blog.ex
* creating test/hello/blog/blog_test.exs
* injecting test/hello/blog/blog_test.exs

mix系のコマンドに一般的に言えることですが、次に何やったらいいか注釈がでてくれます。

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/posts", PostController

Remember to update your repository by running migrations:

    $ mix ecto.migrate

ルーティングの設定とマイグレーションしろよ!と出ています。基本これだけでリソースが作成できます。

大体無いとは思うんですが、Contextいらないよ!って時(Bootstrapのテストなど、なんらかのテストに使う?)は--no-contextというオプションがあるそうです。
活用法があまり思い浮かばないので省略。 同様に--no-schemaオプションもあり。

mix phx.gen.json

こちらは使ったことはないですが、見た目からして上記のJSON版といったところでしょうか。

$ mix phx.gen.json Blog Post posts title:string content:string
* creating lib/hello_web/controllers/post_controller.ex
* creating lib/hello_web/views/post_view.ex
* creating test/hello_web/controllers/post_controller_test.exs
* creating lib/hello_web/views/changeset_view.ex
* creating lib/hello_web/controllers/fallback_controller.ex
* creating lib/hello/blog/post.ex
* creating priv/repo/migrations/20170906153323_create_posts.exs
* creating lib/hello/blog/blog.ex
* injecting lib/hello/blog/blog.ex
* creating test/hello/blog/blog_test.exs
* injecting test/hello/blog/blog_test.exs

コマンドの仕様もほぼhtmlと変わらないようです。
コマンド実行後の注釈はちょっと変わってきています。

Add the resource to your :api scope in lib/hello_web/router.ex:

    resources "/posts", PostController, except: [:new, :edit]


Remember to update your repository by running migrations:

    $ mix ecto.migrate

:new:editのアクションを除外しています。
JSONなので新規生成画面と編集画面がないためでしょう。HTTPリクエストなどで直接:create:updateを実行するということでしょう。

htmlと同様に--no-context--no-schemaオプションもあり。

mix phx.gen.context

Contextの章でAccountsにCredentialを追加するときに使いました。
追加するように使った場合はinjecting lib/hello/accounts.exというように既存のContextに追記する形で生成されましたが、Contextのみ生成したい場合にも利用できるようです。
主な使用方法はhtmljsonの時と同じ。

$ mix phx.gen.context Accounts User users name:string age:integer
* creating lib/hello/accounts/user.ex
* creating priv/repo/migrations/20170906161158_create_users.exs
* creating lib/hello/accounts/accounts.ex
* injecting lib/hello/accounts/accounts.ex
* creating test/hello/accounts/accounts_test.exs
* injecting test/hello/accounts/accounts_test.exs

mix phx.gen.schema

HTMLやJSONのリソースも作らず、Contextも不要というレアケース用?でもコマンドが用意されてるってことは必要なタイミングがあるのかも。
コマンド引数はhtmljsonからContext名を省略したようなかんじ。

$ mix phx.gen.schema Accounts.Credential credentials email:string:unique user_id:references:users
* creating lib/hello/accounts/credential.ex
* creating priv/repo/migrations/20170906162013_create_credentials.exs

mix phx.gen.channel

Channelを生成するためのコマンド。Channelの章では手動で作成しましたが、コマンドでも生成してくれるようです。
引数は純粋にチャンネル名のみ。

$ mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs

ちゃんとメッセージも出してくれます。

Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:

    channel "rooms:lobby", HelloWeb.RoomChannel

mix phx.gen.presence

これはPresenceの章で使いました。
一応モジュール名を指定できますが、省略すればPresenceで作成されます。

$ mix phx.gen.presence
* creating lib/hello_web/channels/presence.ex

こちらもきちんと注釈を付けてくれます。

Add your new module to your supervision tree,
in lib/hello/application.ex:

    children = [
      ...
      HelloWeb.Presence
    ]

You're all set! See the Phoenix.Presence docs for more details:
http://hexdocs.pm/phoenix/Phoenix.Presence.html

mix phx.routes

Routingの章で利用しました。
コマンドを実行すればルーティングルールが表示されます。

$ mix phx.routes
page_path  GET  /  TaskTester.PageController.index/2

ルーティングファイルが2個以上あるときは、ルーティングファイル名を指定するとソイツだけみれるようです。

$ mix phx.routes TaskTesterWeb.Router
page_path  GET  /  TaskTesterWeb.PageController.index/2

mix phx.server

サーバーを立ち上げるコマンド。DoesNotExistオプションとかもあるけどよくわかんない。(本来は「DoesNotExist」が必要だけど省略されてる?)
iexで立ち上げたい時はiex -S mix phx.serverを実行してやればよいそうで。

$ iex -S mix phx.server
Erlang/OTP 17 [erts-6.4] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

[info] Running TaskTesterWeb.Endpoint with Cowboy on port 4000 (http)
Interactive Elixir (1.0.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

これ地味に便利そうですね。

mix phx.digest

静的ファイル(static assets)の名前にダイジェスト(MD5)を付与し、圧縮するコマンドだそうです。
機能的にほぼデプロイ用(改ざん防止、転送量軽減)と考えて問題無さそうです。

assets/ディレクトリにあるファイルに処理を行い、デフォルトではpriv/staticにコピーするようです。
出力されるファイルの場所や処理するファイルの場所を指定したいときは引数で調整可能。

$ mix phx.digest priv/static -o www/public
Check your digested files at 'www/public'.

コンフィグに:を指定すると圧縮するファイルの対象を変更できるようです。

config :phoenix, :gzippable_exts, ~w(.js .css)

役割的にconfig/config.exsあたりにでも書けば良いのでしょうか。

デプロイは別の章でやるので、そのときにもう一度確認します。

飛ばし飛ばしやってるつもりが結構長くなったのでまた次回!

Phoenix入門 (第13章 Contexts その5)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
流石に今回で終わらす!

Cross-context data

CMSとAccountsというContextが介在していますが、場合によっては1個のContextで表現した方がシンプルになり得ます。(規模が小さいときとか)
この辺は各自の判断だとは思うんですが、個人的には今回みたいに細かくContextを作成し、繋がりを最小限にとどめる設計が拡張しやすいんじゃないかとは思います。

依存関係の編集(CMS

なにはともあれ、CMSのContext内でもまだ依存関係を追記していません。その辺を修正していきます。
まずはlib/hello/cms/page.exから。

defmodule Hello.CMS.Page do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.CMS.Author # 追記

  schema "pages" do
    field :body, :string
    field :title, :string
    field :views, :integer
    belongs_to :author, Author # 追記
...

PageはAuthorに対して従属関係なのでこれで良さそうです。
お次はlib/hello/cms/author.ex

defmodule Hello.CMS.Author do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.CMS.Page # 追記

  schema "authors" do
    field :bio, :string
    field :genre, :string
    field :role, :string
#    field :user_id, :id #消去
    has_many :pages, Page # 追記
    belongs_to :user, Hello.Accounts.User # 追記
...

pagesに対してAuthorは一対多です。なのでhas_manyを指定します。
userに対しては一対一の従属関係なのでbelongs_toです。
belongs_toの項目があるので:user_idの行は不要です。

ContextのAPI編集

preloadの追加

お次にContextのlist_pagesとかを編集していきます。
Accounts.Credentialを追加したときのように、Pageがロードされる前に従属関係であるAuthorをpreloadしておく必要があります。
lib/hello/cms.exを編集します。

defmodule Hello.CMS do

...

  alias Hello.CMS.{Page, Author}
  alias Hello.Accounts

...

  def list_pages do
    Page# Repoにしててエラーが起こった。詳細はもうちょっと先の項目で。
    |> Repo.all()
    |> Repo.preload(author: [user: :credential])
  end

...

  def get_page!(id) do
    Page
    |> Repo.get!(id)
    |> Repo.preload(author: [user: :credential])
  end

...

#  alias Hello.CMS.Author # 消去(上でaliasしてるので)

...

  def get_author!(id) do
    Author
    |> Repo.get!(id)
    |> Repo.preload(user: :credential)
  end

...

どうやらRepo.preload(author: [user: :credential])でAuthor、User、Credentialがロードされるようです。

ページを生成するときや編集する時のAuthorの取り扱い

先ほどはデータアクセス(読み込み)の際に必要なpreloadを追加しました。
ではここでは書き込みの際に必要な処理を追記していきます。
同様にlib/hello/cms.exを編集していきます。

...

  def create_page(%Author{} = author, attrs \\ %{}) do
    %Page{}
    |> Page.changeset(attrs)
    |> Ecto.Changeset.put_change(:author_id, author.id)
    |> Repo.insert()
  end

  def ensure_author_exists(%Accounts.User{} = user) do
    %Author{user_id: user.id}
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.unique_constraint(:user_id)
    |> Repo.insert()
    |> handle_existing_author()
  end
  defp handle_existing_author({:ok, author}), do: author
  defp handle_existing_author({:error, changeset}) do
    Repo.get_by!(Author, user_id: changeset.data.user_id)
  end

...

まずはcreate_pageの変更点ですが、引数にAuthorの構造体が必要になりました。
Ecto.Changeset.put_change(:author_id, author.id)でPageの構造体にAuthorのIDを追加し、リポジトリに保存という流れです。

次にensure_author_exists以降の箇所ですが、まず前提を整理します。
このCMSはページを作る前には作成者(Author)の情報が必要(生成するページ情報に紐付くので)です。ですので、もしページを作ろうとしているユーザーがAuthorに登録されていなければ登録するというような処理が必要になります。
フォームを用意してバリデーションではじくという方針も考えられそうですがいちいち手間ですし、バックエンドで処理するべきです。ですので、そういった処理用の関数ensure_author_existsを作成しているのでしょう。

内容は、アカウント情報を渡してAuthorとしてリポジトリに保存し、その後handle_exisiting_authorリポジトリに挿入できたか否かをチェックしているようです。
Ecto.Changeset.unique_constraint(:user_id)でAuthor中に同じuser_idのAuthorがいないかチェックする情報を乗せて、Repo.insert()の際に判断されるようです。既に存在している場合はRepo.get_by!でAuthorの情報を返すようになっているようです。

以上でCMSのContextはだいたいおっけーのはずです。

Webレイヤーの実装

お次はCMSのページを作成したりする画面を編集していきます。
ログインできるかどうかは前回確認しましたが、ページを生成するあたりはスルーしました。(リストは表示されてましたが。)
ページを生成するにあたっては、上の項目で作成したensure_author_existsを利用してユーザーがAuthorに登録されているかどうか、されていなければ登録するPlugを作成してこのPlugを通過するように編集するのが良さそうです。

作成者関係のPlugを作成する

これはコントローラ内に実装します。
lib/hello_web/controllers/cms/page_controller.exを編集します。

defmodule HelloWeb.CMS.PageController do
  use HelloWeb, :controller

  alias Hello.CMS
  alias Hello.CMS.Page

  plug :require_existing_author # 追加
  plug :authorize_page when action in [:edit, :update, :delete] # 追加

...

# 以下追加
  defp require_existing_author(conn, _) do
    author = CMS.ensure_author_exists(conn.assigns.current_user)
    assign(conn, :current_author, author)
  end

  defp authorize_page(conn, _) do
    page = CMS.get_page!(conn.params["id"])

    if conn.assigns.current_author.id == page.author_id do
      assign(conn, :page, page)
    else
      conn
      |> put_flash(:error, "You can't modify that page")
      |> redirect(to: Routes.cms_page_path(conn, :index))
      |> halt()
    end
  end # 忘れてた
end

require_existing_authorauthorize_pageという2種類のPlugを作成しました。
require_existing_authorは分かりやすく、ensure_author_existsを利用して接続情報にAuthorの情報を載せています。(無ければ作ってくれる)
authorize_pageは対象のページの編集者と閲覧者が一致する場合のみ正常に動作し、一致しない場合はフラッシュメッセージを送出してPlugの処理を止めています。このプラグは編集と削除の時だけでいいのでwhen action in~~のオプションを使用しております。
ついでにこのプラグ内でページのIDを取得し、ページ情報も接続情報に登録(assign)しています。

Plugや他の変更に合わせてコントローラを修正する

これらを踏まえてcreateeditupdatedeleteを編集します。

...

  def create(conn, %{"page" => page_params}) do
    # create_pageは上の項目でAuthorの情報が必要になった
    # あと, page_paramsを忘れていた
    case CMS.create_page(conn.assigns.current_author, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page created successfully.")
        |> redirect(to: Routes.cms_page_path(conn, :show, page))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

...

  # idの情報はconnに載ってるので冗長
  def edit(conn, _) do
    # connからページ情報を取り出す
    changeset = CMS.change_page(conn.assigns.page)
    # ページ情報はconnに載ってるので冗長
    render(conn, "edit.html", changeset: changeset)
  end

...

  # idの情報はconnに載ってるので冗長
  def update(conn, %{"page" => page_params}) do
    # connからページ情報を取り出す
    case CMS.update_page(conn.assigns.page, page_params) do
      {:ok, page} ->
        conn
        |> put_flash(:info, "Page updated successfully.")
        |> redirect(to: Routes.cms_page_path(conn, :show, page))

      {:error, %Ecto.Changeset{} = changeset} ->
        # ページ情報はconnに載ってるので冗長
        render(conn, "edit.html", changeset: changeset)
    end
  end

  # idの情報はconnに載ってるので冗長
  def delete(conn, _) do
    # connからページ情報を取り出す
    {:ok, _page} = CMS.delete_page(conn.assigns.page)

    conn
    |> put_flash(:info, "Page deleted successfully.")
    |> redirect(to: Routes.cms_page_path(conn, :index))
  end

...

大量に修正点がありますが、主にauthorize_pageで既にページ情報はPlug.Connにロードしてるのでそこから取り出すように変更したところです。

作成者名を表示する

ついでにページの表示に作成者を表示するように修正しておきます。
まずはビューのlib/hello_web/views/cms/page_view.exを編集します。

defmodule HelloWeb.CMS.PageView do
  use HelloWeb, :view

  alias Hello.CMS

  def author_name(%CMS.Page{author: author}) do
    author.user.name
  end
end

ページ情報に基づき、作成者の名前情報を返す関数を作成しました。
これをテンプレートで呼びます。lib/hello_web/templates/cms/page/show.html.eexを編集します。

...

  <li>
    <strong>Views:</strong>
    <%= @page.views %>
  </li>

  <li>
    <strong>Author:</strong>
    <%= author_name(@page) %>
  </li>

</ul>

...

他の項目と並べるように作成者の表示を追加しました。これでOKな筈です。

mix phx.serverで試す(間違い探し)

それではPageは閲覧数以外もう大丈夫な筈なのでチェックしてみます。
mix phx.serverで実行します。

PS \hello> mix phx.server
Compiling 2 files (.ex)

== Compilation error in file lib/hello_web/controllers/cms/page_controller.ex ==
** (TokenMissingError) lib/hello_web/controllers/cms/page_controller.ex:79: missing terminator: end (for "do" starting at line 1)
    (elixir) lib/kernel/parallel_compiler.ex:208: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/6

lib/hello_web/controllers/cms/page_controller.exの最後の箇所でエラーが出ています。
end忘れでした。再実行。
サーバーは立ち上がったのでhttp://localhost:4000/cms/pagesにアクセス。

Request: GET /cms/pages
** (exit) an exception was raised:
    ** (Protocol.UndefinedError) protocol Ecto.Queryable not implemented for Hello.Repo, the
given module does not provide a schema. This protocol is implemented for: Atom, BitString, Ecto.Query, Ecto.SubQuery, Tuple
        (ecto) lib/ecto/queryable.ex:40: Ecto.Queryable.Atom.to_query/1
        (ecto) lib/ecto/repo/queryable.ex:14: Ecto.Repo.Queryable.all/3
        (hello) lib/hello/cms.ex:23: Hello.CMS.list_pages/0
...

('ω')。o(????????????)
Ecto絡みということはWebレイヤー側の実装ミスでは無い筈。
Context周りを確認します。list_pagesって書いてあるしそのへん?

...

  def list_pages do
    Repo # ←Pageやんけ・・・
    |> Repo.all()
    |> Repo.preload(author: [user: :credential])
  end

...

寝てたんかな。PageをRepoとタイポ。
修正して再アクセス。

f:id:ysmn_deus:20190404125044p:plain

表示はできたけどセッションが生きてる?
ログアウトしたらCSRFと勘違いされたので、サーバーを再起動。
の後再ログイン。

f:id:ysmn_deus:20190404125339p:plain

作成ページは問題無さそう。作成してみる。

f:id:ysmn_deus:20190404125445p:plain

タイトルと本文ちゃんと入力したのに怒られた・・・
まずはコントローラーを疑ってみる。

...

  def create(conn, %{"page" => page_params}) do
    # , page_params ないやん ↓
    case CMS.create_page(conn.assigns.current_author) do
      {:ok, page} ->

...

そらそうなるわ。修正してもう一度。

f:id:ysmn_deus:20190404130230p:plain

こんどはうまくいった!
一応別のユーザーを作成して編集できないかチェック。

f:id:ysmn_deus:20190404130546p:plain

authorize_pageのPlugもよさそうです。

Adding CMS functions

AccountsのContextにauthenticate_by_email_password/2という関数を作成して機能を拡張したようにCMSにも機能を追加で来ます。Contextを使って閲覧数をカウントアップする機能を追加していきます。

機能の仕様を考える

編集したときと同じ様にCMS.update_pageで実装するのは色々と問題があります。
まず第一に競合が起こりやすくなります。PV数がかなり少ないようなショボいケースであればほぼ問題無いのですが、複数人が同時にアクセスした場合にカウントが正しくないケースが考えられます。
例えば

  1. User 1がカウント13のページをロードする
  2. User 1がページのカウントを14にする
  3. User 2がカウント14のページをロードする
  4. User 2がページのカウントを15にする

こうなれば良いんですが、同時アクセスなどがある場合下記が考えられます。

  1. User 1がカウント13のページをロードする
  2. User 2がカウント13のページをロードする
  3. User 1がページのカウントを14にする
  4. User 2がカウント14のページをロードする
  5. User 2がページのカウントを14にする

ではどのような仕様が望ましいか。
ページ情報のロードの際にインクリメントして呼び出されるのが望ましいです。つまり

page = CMS.inc_page_views(page)

となるようなinc_page_views/1を定義して、Page情報取得の箇所にパイプラインとして渡せば綺麗におさまりそうです。

CMSに実装する

それではCMSのContextに実装していきます。
lib/hello/cms.exを編集します。

...

  def inc_page_views(%Page{} = page) do
    {1, [%Page{views: views}]} =
      Repo.update_all(
        from(p in Page, where: p.id == ^page.id),
        [inc: [views: 1]], returning: [:views])
  
    put_in(page.views, views)
  end

...

場所はどこでも良いと思いますが、一応Context内の整理を考慮してchange_page関数の次に記載しました。
Ectoのクエリを詳しく見る必要がありそうですが、Repo.update_allの箇所はfrom(p in Page, where: p.id == ^page.id)で該当ページをヒットさせ、[inc: [views: 1]]でヒットしたPageのviewsを1インクリメントして保存し、返値にviewsを要求する、という処理のようです。
PageのIDはユニークなので成功すれば、帰ってくるタプルは必ず{1(updateした項目数), hogehoge}となるというところでしょう。
put_inpage.viewsviewsの値を代入し、pageを返すといった流れでしょうか。

これでinc_page_viewsの実装は良さそうです。
いつものごとくWebレイヤーを修正していきます。

コントローラを修正する

HTTPアクセスの度に呼び出される処理はコントローラの役目です。lib/hello_web/controllers/cms/page_controller.exを編集します。

...

  def show(conn, %{"id" => id}) do
    page = 
    id
    |> CMS.get_page!()
    |> CMS.inc_page_views()
    
    render(conn, "show.html", page: page)
  end

...

閲覧した時にインクリメントされて欲しいのでshowに先ほどの機能を実装します。
これで実装は完了されたはずです。サーバーを起動して確認してみます。

f:id:ysmn_deus:20190404134021p:plain
1回目

f:id:ysmn_deus:20190404134039p:plain
2回目

インクリメントされてます!完成です!

パスワード認証などははしょりましたが、これでCMSまでをも作る事ができました。
割とこの辺は他のシステムなどにも応用が利きそうです。

FAQ

Returning Ecto structures from context APIs

(Contextはカプセル化するための概念なのになんでcreate_user/1みたいな関数は失敗したときにEcto.Changesetを生で返すねん!)

Phoenixでは%Ecto.Changeset{}は一般的な構造体として考えられているそうで、Phoenix.ParamPhoenix.HTML.FormDataでハンドリングできるそうです。あと、エラーメッセージとかバリデーションのどこがアカンかったとか見やすいからだそうで。

Strategies for cross-context workflows

今回はページを生成する際にAuthorをバックエンドで作成してましたが、これは必ずしも全ユーザーがAuthorである必要性がない前提です。もしユーザーがAuthorのデータを必要とするときはどのような依存関係になるのでしょうか。

アカウントを生成する際に依存関係を結ぶようなケースを考えると、たとえばAccountsのContextのcreate_user

def create_user(attrs) do
  %User{}
  |> User.changeset(attrs)
  |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
  |> Ecto.Changeset.put_assoc(:author, %Author{...}) # これ
  |> Repo.insert()
end

となります。
一見問題無さそうに見えますが、この設計だとCMSはAccountsの構造に依存しており、AccountsもまたCMSの構造に依存することになります。
AccountsはCMSから完全に独立しているからこそ拡張性が高いのですが、上記のような循環参照みたいな関係になってしまうと拡張性も糞も無くなります。

ただ、よくある事のようなので、こういうケースは新しくContextを生成するのがベストプラクティスとされているようです。

例えば、今回であればAccountsとCMSはそのままで、UserRegistrationというContextを新しく作ります。このContextからAccountsとCMSを呼び出し、CMSのAuthorの関連付けを行います。こうすることでAccountsとCMSのそれぞれの結びつきを最小限にできるだけでなく、APIとしても明瞭(Context名から何を意味しているのかが推測できたりとか?)になるはずです。このアプローチを採用する際にはEcto.Multiが有用だそうです。
例を見ます。

defmodule Hello.UserRegistration do
  alias Ecto.Multi
  alias Hello.{Accounts, CMS}

  def register_user(params) do
    Multi.new()
    |> Multi.run(:user, fn _ -> Accounts.create_user(params) end)
    |> Multi.run(:author, fn %{user: user} ->
      {:ok, CMS.ensure_author_exists(user)}
    end)
    |> Repo.transaction()
  end
end

Multi.new()から続くパイプラインでそれぞれの依存関係の処理を行い、最後にトランザクション処理がなされる流れです。
もしどこかの処理で失敗すれば、全てがロールバックする仕組みだそうです。

おわりに

Context長かったのですが、割とPhoenixの中枢の話な気はします。
また復習してMulti.new()あたりも活用できるサンプルを公開できればなぁと思います。

Phoenix入門 (第13章 Contexts その4)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
前回かなり中途半端なところで終わりましたが、気にせず続けます。

Cross-context dependencies

前回はCMSってContextを作ろう!というところで終わってました。なので作って行きます。

スキーム

今回は

  • title:string タイトル。文字列。
  • body:string 本文。文字列。
  • views:integer 閲覧数?数値。

というモデルになっているようです。

mix phx.gen.htmlでContext生成

PS \hello> mix phx.gen.html CMS Page pages title:string body:text views:integer --web CMS
* creating lib/hello_web/controllers/cms/page_controller.ex
* creating lib/hello_web/templates/cms/page/edit.html.eex
* creating lib/hello_web/templates/cms/page/form.html.eex
* creating lib/hello_web/templates/cms/page/index.html.eex
* creating lib/hello_web/templates/cms/page/new.html.eex
* creating lib/hello_web/templates/cms/page/show.html.eex
* creating lib/hello_web/views/cms/page_view.ex
* creating test/hello_web/controllers/cms/page_controller_test.exs
* creating lib/hello/cms/page.ex
* creating priv/repo/migrations/20190403003143_create_pages.exs
* creating lib/hello/cms.ex
* injecting lib/hello/cms.ex
* creating test/hello/cms/cms_test.exs
* injecting test/hello/cms/cms_test.exs

Add the resource to your CMS :browser scope in lib/hello_web/router.ex:

    scope "/cms", HelloWeb.CMS, as: :cms do
      pipe_through :browser
      ...
      resources "/pages", PageController
    end


Remember to update your repository by running migrations:

    $ mix ecto.migrate

復習にはなりますがmix phx.gen.html Context名 モデル名(コントローラとかで使われる) スキーム名 スキーム (--web 名前空間)でリソースに必要なファイルやContextが生成できます。
--web 名前空間のオプションですが、今回Pageという名前のモデル?モジュール名を利用しているので、そのまま使うとPageControllerとかが被ってしまいます。
そういうときにはこのオプションを利用するようです。(基本的にContext毎に分けた方がいいのでは?とは思いますが、まだ慣れてないので今はそういう物だと考えておきます。)
生成されているファイルがlib/hello_web/controllers/cms/page_controller.exとなっている所からも重複が回避されているのが分かります。

スキームに合わせたテンプレートの調整

viewsという項目はユーザーが直接編集するものではないのでフォームのビューからは消しておきます。lib/hello_web/templates/cms/page/form.html.eexviewsに該当する箇所を消去します。

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :title %>
  <%= text_input f, :title %>
  <%= error_tag f, :title %>

  <%= label f, :body %>
  <%= textarea f, :body %>
  <%= error_tag f, :body %>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

あっても動くとは思いますがドキュメントに従いましょう。

スキームに合わせたchangesetの変更

上記と同様の理由でスキームに付属するchangesetのバリデーションも変更します。
Contextで対応するのかな?と思っていましたがchangesetで落としてしまうようです。
lib/hello/cms/page.exを編集します。

...
  @doc false
  def changeset(page, attrs) do
    page
    |> cast(attrs, [:title, :body]) # :viewsを消去
    |> validate_required([:title, :body]) # :viewsを消去
  end
end

マイグレーションファイルの調整

priv/repo/migrationsの該当するファイル(mix phx.gen.htmlで生成されたやつ)を編集します。

defmodule Hello.Repo.Migrations.CreatePages do
  use Ecto.Migration

  def change do
    create table(:pages) do
      add :title, :string
      add :body, :text
      add :views, :integer, default: 0 # この箇所にデフォルト値を設定

      timestamps()
    end

  end
end

今回はスキームの変更によるものでは無いですが、おおよそスキームを変更した場合はマイグレーションファイルの調整が必要になるでしょう。

ルーティングの追加(router.ex)

CMSのContextを追加したのでPageのリソースが見れる様にします。
lib/hello_web/router.exを修正します。

...
  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
    resources "/sessions", SessionController, only: [:new, :create, :delete], singleton: true
  end

  scope "/cms", HelloWeb.CMS, as: :cms do
    pipe_through [:browser, :authenticate_user]

    resources "/pages", PageController
  end
...

scope "/", HelloWeb do句を真似て書けば問題無いと思います。
ただし、今回はCMSに関連するページは認証が必要なようにしたいので:authenticate_userプラグを適応するようpipe_through [:browser, :authenticate_user]となっています。

マイグレートと認証機能の確認

ここまで来ると機能は実装されてないものの、だいたいの表示はできる筈です。
mix ecto.migrateでデータベースをマイグレートしてサーバーを起動してみます。

PS \hello> mix ecto.migrate
Compiling 6 files (.ex)
Generated hello app
[info] == Running 20190403003143 Hello.Repo.Migrations.CreatePages.change/0 forward
[info] create table pages
[info] == Migrated 20190403003143 in 0.0s
PS \hello> mix phx.server

生成したPageのURLはhttp://localhost:4000/cms/pagesです。ここにアクセスしてみます。

f:id:ysmn_deus:20190403100232p:plain

ログインしていないのでput_flashで怒られています。
http://localhost:4000/sessions/newにて、前回までに作成したユーザーでログインしてみます。

f:id:ysmn_deus:20190403102543p:plain

ヨシ!
認証系の機能がここまで簡単に追加できるのは驚愕です。
Plugの拡張性の高さヤバイ。

Authorの追加

ではPageを追加・・・と行きたいところですが、作成者の情報を紐付けておかないと面倒なことになりそうなので先にそちらを済ませましょう。
emailの情報をAccountsのContextに追加したようにphx.gen.contextCMSのContextにAuthorの情報を追加していきます。

スキームはこんなかんじ。

  • bio:text 出身?text形?昔のバージョンによく見られるっぽいんですが、たぶん:stringと同義。生成されたスキームでは:stringになってた。
  • role:string 役割。文字列。
  • genre:string ジャンル。文字列。
  • user_id:references:users:unique ユーザーID、ユーザーの情報と関連している
PS \hello> mix phx.gen.context CMS Author authors bio:text role:string genre:string user_id:references:users:unique
You are generating into an existing context.
The Hello.CMS context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/hello/cms/author.ex
* creating priv/repo/migrations/20190403013056_create_authors.exs
* injecting lib/hello/cms.ex
* injecting test/hello/cms/cms_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

UserとAuthorのContextを分けておくことで開発上いろんな利点がありそうです。
Authorに別情報が必要になってCMSのContextを編集する際でもAccounts側のUserは変更不要ですしCMS側にAcountsのUserデータ以外が流出することもないです。

マイグレーションファイルの調整

Accountsで認証情報を追加したときのように、従属関係にある際はon_deletenull: falseを設定しておいた方が良いです。(不用意にデータが残ってしまう可能性がある。)
なのでpriv/repo/migrationsに先ほどのmix phx.gen.contextで生成されたマイグレーションファイルを調整します。

defmodule Hello.Repo.Migrations.CreateAuthors do
  use Ecto.Migration

  def change do
    create table(:authors) do
      add :bio, :text
      add :role, :string
      add :genre, :string
      add :user_id, references(:users, on_delete: :delete_all), null: false # ここを修正

      timestamps()
    end

    create unique_index(:authors, [:user_id])
  end
end

これでAuthorに関しては問題ありませんが、まだ従属関係はあります。
PageがAuthorに関連付いているので、この関連付けをデータベースに登録するためにマイグレーションファイルを作成します。
mix ecto.gen.migrationで任意のマイグレーションファイルを作成できるようです。

PS \hello> mix ecto.gen.migration add_author_id_to_pages
Compiling 2 files (.ex)
Generated hello app
* creating priv/repo/migrations/20190403015018_add_author_id_to_pages.exs

いつものようなタイムスタンプ+名前.exsが生成されました。
一応中身を見てみます。

defmodule Hello.Repo.Migrations.AddAuthorIdToPages do
  use Ecto.Migration

  def change do

  end
end

このchange関数の中に処理を記載してマイグレーションすれば良さそうです。
いつものマイグレーションファイルはcreate table(hogehoge)doなどが記載されています。

今回はドキュメントに従い、下記のようにしました。

defmodule Hello.Repo.Migrations.AddAuthorIdToPages do
  use Ecto.Migration

  def change do
    alter table(:pages) do
      add :author_id, references(:authors, on_delete: :delete_all), null: false
    end

    create index(:pages, [:author_id])
  end
end

create tableでテーブル作成ですが、alter tableでテーブルにカラムを追加できるようです。
add以降は割といつも通りでしょうか。
作成者が削除されたらページも削除されてしまうのはなんだか寂しい気がしますが、今回はCMSを作る練習と言うことでスルーします。

マイグレーションファイルの調整が完了すれば、やることは一つ。mix ecto.migrateマイグレーションします。

PS \hello> mix ecto.migrate
warning: variable "null" does not exist and is being expanded to "null()", please use parentheses to remove the ambiguity or change the variable name
  priv/repo/migrations/20190403013056_create_authors.exs:9

** (CompileError) priv/repo/migrations/20190403013056_create_authors.exs:9: undefined function add/4
    (elixir) src/elixir_locals.erl:107: :elixir_locals."-ensure_no_undefined_local/3-lc$^0/1-0-"/2
    (elixir) src/elixir_locals.erl:107: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir) lib/code.ex:715: Code.load_file/2
    (ecto_sql) lib/ecto/migrator.ex:489: Ecto.Migrator.load_migration/1
    (elixir) lib/enum.ex:1327: Enum."-map/2-lists^map/1-0-"/2
    (ecto_sql) lib/ecto/migrator.ex:435: Ecto.Migrator.do_migrate/4
    (ecto_sql) lib/ecto/migrator.ex:429: Ecto.Migrator.migrate/4
    (ecto_sql) lib/ecto/adapters/sql.ex:820: anonymous fn/3 in Ecto.Adapters.SQL.checkout_or_transaction/4
    (db_connection) lib/db_connection.ex:1415: DBConnection.run_transaction/4
    (ecto_sql) lib/ecto/adapters/sql.ex:727: Ecto.Adapters.SQL.lock_for_migrations/5
    (ecto_sql) lib/ecto/migrator.ex:318: Ecto.Migrator.lock_for_migrations/3
    (ecto_sql) lib/mix/tasks/ecto.migrate.ex:110: anonymous fn/4 in Mix.Tasks.Ecto.Migrate.run/2

null: falsenull, falseって打ってた、アホス。
修正して再実行。

PS \hello> mix ecto.migrate
[info] == Running 20190403013056 Hello.Repo.Migrations.CreateAuthors.change/0 forward
[info] create table authors
[info] create index authors_user_id_index
[info] == Migrated 20190403013056 in 0.0s
[info] == Running 20190403015018 Hello.Repo.Migrations.AddAuthorIdToPages.change/0 forward
[info] alter table pages
[info] create index pages_author_id_index
[info] == Migrated 20190403015018 in 0.0s

問題無さそう。
データベース周りの準備は良さそうなのでCMSとしての機能を実装していきます。

かなり長くなってるのでまた区切ります。
流石に次回アタリでケリをつけたい。

Phoenix入門 (第13章 Contexts その3)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
Contextsの3回目です。なんか3回じゃ終わらないかも・・・(´゚'ω゚`)

前回はContext内でリレーションを作りました。
今回はContextに機能を追加していくところになるかと。

Adding Account functions

とりあえず前回まででユーザー情報(IDとパスワード)とメール情報のCRUDはできました。(メールはユーザー情報経由ですが。)
ですが、基本的な機能だけで認証機能とかはまだありません。
お次は認証情報(今回はメールアドレス)を使ってセッションを作成したりするお話っぽいです。

想定としては、メールアドレスとパスワードを入力してユーザー情報を取得する、という流れです。
なので

> user = Accounts.authenticate_by_email_password(email, password)

という形でユーザー情報が得られ、この情報をセッションに渡せば良さそうです。(ユーザー情報のやりとりなんかはChannelの章あたりが参考になるかもしれません。)

Contextに関数を追加

この関数をContextのlib/hello/accounts.exに追記します。

...
  def authenticate_by_email_password(email, _password) do
    query =
      from u in User,
        inner_join: c in assoc(u, :credential),
        where: c.email == ^email
  
    case Repo.one(query) do
      %User{} = user -> {:ok, user}
      nil -> {:error, :unauthorized}
    end
  end
end

一番最後に追記しました。

ここで、^emailとなっていて、「ピンオペレータ?」と思ったんですが右辺だしなんやねんこれ、と思っていたんですがどうやらEctoのクエリとして外部の変数(クエリ内で出てこない、Elixir上の変数)を利用するときはハット(^)を変数の名前の前に付けるそうです。

hexdocs.pm

もうちょっとEctoと仲良くなる必要があるかもしれません。

とりあえずこの章ではパスワードは破棄してますが、もしパスワード認証を利用したい場合はGuardianComeoninというトークンを発行したりハッシュ化したりするElixirのライブラリがあるそうなので、それらを使うのが良いそうです。
今回はメールアドレスがあるかないかだけ判定します。

Webレイヤーを実装

それでは先ほどの関数を利用して認証ページのようなものを作成していきます。
なにはともあれまずはコントローラです。lib/hello_web/controllers/session_controller.exを作成します。




大枠は普通のコントローラと同じです。createでセッションを生成してPlug.ConnにユーザーIDを渡しています。(本来であればトークンの方が望ましい?)
configure_sessionあたりはセッションID固定化攻撃(Session Fixation)対策だそうです。

次に、ルーターを編集します。lib/hello_web/router.exです。

...
  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/users", UserController
    resources "/sessions", SessionController, only: [:new, :create, :delete], singleton: true
  end
...

resources "/sessions"の行を追記しました。
コントローラには:new, :create, :deleteの3つしか実装していないので、resourcesの3つを利用するよう:onlyオプションを利用しています。
また、singletonオプションはURIにリソースのIDを作成しない設定だそうです。今回はセッションの作成用のURIなので/sessions/12345のようなIDは不要です。(セッション情報は接続情報に載っていくので)

あとは認証確認をPlugにしてルーター内で認証機能が必要なときに接続情報のパイプラインに乗せれるようにしておきます。同様にrouter.exの最後にでも書いときます。

...
  defp authenticate_user(conn, _) do
    case get_session(conn, :user_id) do
      nil ->
        conn
        |> Phoenix.Controller.put_flash(:error, "Login required")
        |> Phoenix.Controller.redirect(to: "/")
        |> halt()
      user_id ->
        assign(conn, :current_user, Hello.Accounts.get_user!(user_id))
    end
  end
end

use HelloWeb, :routerがあるのでrouter.exではimport Plug.Connが既になされています。なのでPlug.Conn.get_session/2get_session/2として利用できます。
この処理で:user_idがセッション情報内にあるかチェックしています。無ければput_flashでメッセージを送信してルートページへリダイレクトの後、Plug処理を停止という流れです。
あればセッション情報内に:current_userというキーでユーザーidを登録します。

このプラグは今は使いませんが、後々使うそうです。

ウェブレイヤー実装の最後にセッション生成用のビューを作成します。
lib/hello_web/views/session_view.exを作成して記載していきます。

defmodule HelloWeb.SessionView do
    use HelloWeb, :view
end

特に処理は不要なのでいたってシンプル。
テンプレートを作成します。lib/hello_web/templates/session/new.html.eex

<h1>Sign in</h1>

<%= form_for @conn, Routes.session_path(@conn, :create), [method: :post, as: :user], fn f -> %>
  <div class="form-group">
    <%= text_input f, :email, placeholder: "Email" %>
  </div>

  <div class="form-group">
    <%= password_input f, :password, placeholder: "Password" %>
  </div>

  <div class="form-group">
    <%= submit "Login" %>
  </div>
<% end %>

<%= form_for @conn, Routes.session_path(@conn, :delete), [method: :delete, as: :user], fn _ -> %>
  <div class="form-group">
    <%= submit "logout" %>
  </div>
<% end %>

シンプルなフォームです。
ここまで来ると、ようやくウェブ上で確認できます。
http://localhost:4000/sessions/newにアクセスします。

f:id:ysmn_deus:20190402105043p:plain

とりあえず表示は問題無さそうです。
試しに空送信してみると、きちんとエラーが表示されます。
パスワードは無視していますが、メールアドレスはデータベースを参照しています。試しに前回作成したメールアドレスでログインできるか入力します。

f:id:ysmn_deus:20190402105258p:plain

メールアドレスがあっていればルートページにリダイレクトされ、「Welcome back!」のメッセージが着いていると思います。

きりが良いのでココで終わらせたいのですが、逆にキリが悪い方が学習効率は上がる(ツァイガルニク効果)筈なのであとちょっとだけ進めます。

Cross-context dependencies

ユーザー認証はもう良さそうです(ホントはトークン発行にしたりなどありますが)。
お次はCMSたる所以のページをマネージする機能を実装していきたいと思います。

想定としては承認されたユーザー(管理者的な)はページを作成したり修正したりできるといったものです。現状ではAccountsというContextが存在してますが、CMSの機能はアカウント管理機能とは分離されていないと機能を実装したり拡張したりするときに非常にヤヤコシイことになります。ですのでContextを分けます。
ただし、タイトル的にはContext同士で依存関係をかくことにはなると思います。(100%分離するのは無理)

ということでCMSのContextを作成していくのですが、CMSの仕様を考えておきましょう。

  1. ページを作成したりアップデートしたりする
  2. ページは作成者に従属し、作成者に変更する権限がある
  3. 作成者の情報がページ上にあり、その役割("editior"や"writer"など)の情報もある

といったところでしょうか。
仕様やからページのリソースが必要なのは明確ですが、あとは「作成者」をどうするかです。
Accountsを拡張して役割を付与するのも手ですが、関係性がかなり複雑になるのが目に見えています。
ここではCMSのContextにAuthorというスキーマを作成し、このAuthorAccountsUserを関連付けるのが良さそうです。
基本的にユーザー情報と紐付けてなんかする、なんてのは山のようにあると思いますので、この辺の設計はこの章の設計を踏襲するのがよさそうです。というか、Accountsの設計はなんにでも流用できそうです。

さて、次回はmix phx.gen.htmlCMSのContextを作成して行きます。

Phoenix入門 (第13章 Contexts その2)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
引き続きContextsです。この分量だと3回に別れるかな・・・

In-context Relationships

Relationshipsということはようやくテーブルの関連付けなどでしょうか。

前回までに作成したAccountuserですが、メールアドレスなど登録情報が何もありません。(パスワードはありますが・・・)
そこで、ログイン用のメールアドレスを追加するのですが、これをuserテーブルに追加してしまうと外部認証(Googleアカウントとかでログインするやつ)に対応しようとすると大変なことになります。
なのでアカウント情報にメールアドレスのデータを紐付けて管理することにします。

Contextに追加する

そこでまたContextを作成していくのですが、前回作成したAccountにContextを追加していくようなことができるそうです。
phx.gen.htmlのようなコマンド、mix phx.gen.contextで追加していきます。

PS \hello> mix phx.gen.context Accounts Credential credentials email:string:unique user_id:references:users
You are generating into an existing context.
The Hello.Accounts context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as     long as they are closely related
  * If they are not closely related, another context probably works better

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] Y
* creating lib/hello/accounts/credential.ex
* creating priv/repo/migrations/20190401004125_create_credentials.exs
* injecting lib/hello/accounts.ex
* injecting test/hello/accounts/accounts_test.exs

Remember to update your repository by running migrations:

    $ mix ecto.migrate

今回はウェブ上でのCRUD操作は要らないのでmix phx.gen.contextで追加したようです。
(userのviewなどに機能を追加していく)
* injectinglib/hello/accounts.exファイルに追記されているのが分かります。

参照先が削除されたときの振る舞い(ON DELETE)

Credentialの情報(今回はメールアドレスだけ)はユーザーIDと1対1で関連付いているので、usersのデータが消去されたときには消えて欲しいです。
なのでその情報をmigrationsのテーブル追加スクリプトに追記します。
* creating priv/repo/migrations/20190401004125_create_credentials.exsを編集します(環境によってタイムスタンプは違う)。

defmodule Hello.Repo.Migrations.CreateCredentials do
  use Ecto.Migration

  def change do
    create table(:credentials) do
      add :email, :string
      add :user_id, references(:users, on_delete: :delete_all), null: false # :nothing を :delete_all に

      timestamps()
    end

    create unique_index(:credentials, [:email])
    create index(:credentials, [:user_id])
  end
end

, null: falseも追記されてるのは、ユーザーと紐付いてない謎の認証情報が発生しないようにということでしょう。
これで良さそうなのでマイグレーションします。

PS \hello> mix ecto.migrate
Compiling 2 files (.ex)
Generated hello app
[info] == Running 20190401004125 Hello.Repo.Migrations.CreateCredentials.change/0 forward
[info] create table credentials
[info] create index credentials_email_index
[info] create index credentials_user_id_index
[info] == Migrated 20190401004125 in 0.0s

これでデータベース上のテーブルなどは良さそうです。

Contextで扱うスキーマの関係性

次にPhoenix側での関係性も記載しておきます。Contextのlib/hello/accounts/user.exに対応関係を追記します。

defmodule Hello.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.Credential # 忘れないで(戒め)

  schema "users" do
    field :name, :string
    field :username, :string
    has_one :credential, Credential # 追加

    timestamps()
  end
...

has_oneマクロはEctoの機能だそうで、他のスキーマとの関連性をEcto側で使える様にするものだそうです。
同様にcredential.exの方にも関連性を記載します。

defmodule Hello.Accounts.Credential do
  use Ecto.Schema
  import Ecto.Changeset
  alias Hello.Accounts.User # 忘れないで(戒め)

  schema "credentials" do
    field :email, :string
    belongs_to :user, User # 通常のフィールドからbelongs_toに変更

    timestamps()
  end
...

belongs_toマクロは先ほど同様Ectoのマクロで、Userに対して従属していることをEctoに知らせます。
この辺自動でやってくれないかなぁ。

preloadの設定

お次はgetなどの取得時にCredentialのデータを取っておくように書いておく設定をContextに書いておきます。
lib/hello/accounts.exを編集します。

...
  def list_users do
    User
    |> Repo.all()
    |> Repo.preload(:credential)
  end
...
  def get_user!(id) do
    User
    |> Repo.get!(id)
    |> Repo.preload(:credential)
  end
...

Repo.preload(:credential)を入れておくことで%Accounts.User{}の構造体の中にデータが載るようです。
あとなんか効率的にデータベースのデータが取得できる?かもしれないです。この辺はちょっとよく分かりませんでしたが、関連付いてて、十中八九使用するデータはpreloadしておくのが吉かもしれません。

入力のテンプレートに入力フォームを追加

ここまできてようやくウェブ上の方に機能を追加していきます。
まずは入力フォームにemailを記入できるようにlib/hello_web/templates/user/form.html.eexを編集します。

...
  <div class="form-group">
    <%= inputs_for f, :credential, fn cf -> %>
      <%= label cf, :email %>
      <%= text_input cf, :email %>
      <%= error_tag cf, :email %>
    <% end %>
  </div>

  <div>
    <%= submit "Save" %>
  </div>
<% end %>

分かりにくいですが、基本的に<%= ○○ %>を除けばElixirのコードです。
inputs_for関数?はf(ここではUserのフォーム)に関連付いた情報で:credentialの入力を作成する、という構文のようです。fn cf ->以降は他の入力フォームと同じです。
一応ソースに直すのであれば

inputs_for f, :credential, fn cf ->
  label cf, :email
  text_input cf, :email
  error_tag cf, :email
end

のようになってる感じです。各関数?マクロ?は入力のタグを追加するってところでしょうか。

出力のテンプレートに表示を追加

あとは表示を追加しておきましょう。
lib/hello_web/templates/user/show.html.eexを編集します。

<h1>Show User</h1>

<ul>

  <li>
    <strong>Name:</strong>
    <%= @user.name %>
  </li>

  <li>
    <strong>Username:</strong>
    <%= @user.username %>
  </li>

  <li>
    <strong>Email:</strong>
    <%= @user.credential.email %>
  </li>
...

このへんは楽勝ですね。
この時点でPhoenixは機能すると思います。一応mix phx.serverhttp://localhost:4000/users/newにアクセスしてみます。

f:id:ysmn_deus:20190401105646p:plain

('ω')。o(????????????)

UndefinedFunctionError at GET /users/new
function Credential.__struct__/0 is undefined (module Credential is not available)

Credentialが使えない、なんでや。
ちょっとソースを見直した結果、Contextのそれぞれ(lib/hello/accounts/user.excredential.ex)にエイリアスを書くのを忘れていた。アホス。
追記して再度アクセス。

f:id:ysmn_deus:20190401110102p:plain

ヨシ!

ContextのCreateとUpdateを変更(関連データのバリデーション)

とはいっても、現段階ではただ表示されているだけで実際に保存しようとすると

UndefinedFunctionError at GET /users/2
function nil.email/0 is undefined

になると思います。これはContextのupdate_userなどが古いままなのでメール情報を格納するステップが抜けてるので、「メール情報ないやんけ!」と怒られてるのでしょう。
この辺を追加します。Contextのlib/hello/accounts/accounts.exを編集します。

defmodule Hello.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Accounts.{User, Credential} # Accountsの後ろに.をつけ忘れてた
...
  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.insert()
  end
...
  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Ecto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)
    |> Repo.update()
  end
...
#  alias Hello.Accounts.Credential # 消してもヨシ、上で呼んでるので冗長
...

Repo.insertなどする前のパイプラインにEcto.Changeset.cast_assoc(:credential, with: &Credential.changeset/2)を追加しました。
Ecto.Changeset.cast_assoc/3はどうやら第一引数に大元の関連付いた構造体(今回だとユーザーの構造体)、第二引数に関連データを示すアトム(今回だと:credential、たぶんユーザー構造体に追加されたキーとして読み込んでる)、第三引数に関連データのchangesetを渡してバリデーションするといった流れでしょう。

これで行けるそうです。やってみます。

SyntaxError
lib/hello/accounts.ex:9: syntax error before: '{'

(╬´◓ω◔╬) 凡ミスです。alias Hello.Accounts{User, Credential}となっていたのでalias Hello.Accounts.{User, Credential}`に修正。

f:id:ysmn_deus:20190401112417p:plain

行けました。メールアドレスをブランクで保存しようとしても、きちんとバリデーションではじかれます。
もっときっちりするならCredentialでメールアドレスの形式でバリデーションしてもいいかもしれません。

もう一項目行きたかったんですがちょっと長くなってるのでこの辺で区切ります。