Chapter 12

コンテキスト(1/2)

koga1020
koga1020
2021.12.18に更新

コンテキスト

前提: このガイドでは、入門ガイドの内容を理解し、Phoenixアプリケーションを起動していることを前提としています。

前提: リクエストライフサイクルのガイドの内容を前提としています

前提: このガイドでは、Ectoガイドの内容を前提としています

これまでに、ページを構築し、ルーターを介してコントローラーのアクションを接続し、Ectoがどのようにしてデータをバリデートし、永続化するかを学んできました。今度は、より大きなElixirアプリケーションと相互作用するWeb向けの機能を書くことで、すべてを結びつける時が来ました。

Phoenixプロジェクトの構築する際に、何よりもまずElixirアプリケーションの構築を行います。Phoenixの仕事は、ElixirアプリケーションにWebインターフェイスを提供することです。当然のことながら、アプリケーションはモジュールと関数で構成されますが、単にモジュールにいくつかの関数を定義するだけでは、アプリケーションの設計には不十分です。モジュール間の境界や、機能のまとめ方を考える必要があります。つまり、コードを書くときには、アプリケーションの設計を考えることが重要なのです。

設計について考える

コンテキストは、関連する関数を公開したり、グループ化したりする専用モジュールです。たとえば、Logger.info/1Stream.map/2 など、Elixirの標準ライブラリを呼び出すときはいつでも、異なるコンテキストにアクセスしていることになります。内部的には、Elixirのロガーは複数のモジュールで構成されていますが、それらのモジュールと直接やりとりすることはありません。私たちは Logger モジュールをコンテキストと呼んでいますが、これは正確にはすべてのロギング機能を公開し、グループ化しているからです。

関連する機能を公開したり、グループ化したりするモジュールに「コンテキスト」という名前をつけることで、開発者がこれらのパターンを識別し、それについて話し合えるようにしています。結局のところ、コンテキストは、コントローラーやビューなどと同様に、単なるモジュールに過ぎません。

Phoenixでは、コンテキストはデータアクセスやデータ検証をカプセル化することが多いです。コンテキストは、データベースやAPIに接続されます。全体的には、アプリケーションの一部を切り離して分離させるための境界線と考えてください。それでは、これらの考え方を参考にして、Webアプリケーションを構築してみましょう。私たちの目標は、商品を紹介し、ユーザーが商品をカートに入れ、注文を完了できるようなeコマースシステムを構築することです。

このガイドの読み方: コンテキストジェネレーターの使用は、Elixirプログラマーの初心者から中級者まで、アプリケーションを熟考して設計しながら素早く立ち上げることができる素晴らしい方法です。このガイドは、そのような読者に焦点を当てています。一方、経験豊富な開発者は、アプリケーションの設計に関する細かな議論からより多くの利益を得られるかもしれません。そのような方のために、ガイドの最後に「よくある質問(FAQ)」を掲載し、設計に関するさまざまな視点を紹介しています。初心者の方は、FAQセクションを読み飛ばしても問題ありませんが、より深く知りたい場合は、後ほどお読みください。

Catalogコンテキストを追加する

eコマースプラットフォームは、コードベース全体に広く結合しているため、明確なインターフェイスを書くことを前もって考えることが重要です。この点を考慮して、私たちの目標は、システムで利用可能な商品の作成、更新、および削除を処理する商品カタログAPIを構築することです。最初は商品を紹介するという基本的な機能から始めますが、後からカート機能を追加していきます。境界を分離した強固な基盤から始めることで、機能の追加に合わせてアプリケーションを自然に成長させることができることがわかります。

Phoenix には、mix phx.gen.htmlmix phx.gen.jsonmix phx.gen.livemix phx.gen.context の各ジェネレーターが含まれており、アプリケーションの機能をコンテクストに分離するというアイデアを応用しています。これらのジェネレーターは、Phoenixがアプリケーションを成長させるために正しい方向に導いてくれるので、すぐに使い始めることができます。これらのツールを新しい商品カタログのコンテキストに使用してみましょう。

コンテキストジェネレーターを実行するには、構築する関連機能をグループ化したモジュール名を考える必要があります。Ectoガイド では、Changeset と Repos を使用してユーザー スキーマを検証して持続させる方法を見ましたが、これをアプリケーション全体に統合することはしませんでした。実際、私たちのアプリケーションにおける「ユーザー」の居場所については、まったく考えていませんでした。一歩下がって、システムのさまざまな部分について考えてみましょう。商品を販売するページでは、商品の説明や価格などを紹介します。商品の販売と同時に、カートや注文のチェックアウトなどもサポートする必要があることもわかっています。購入される商品はカートやチェックアウトのプロセスに関連していますが、商品を展示したり、商品の展示を管理することは、ユーザーがカートに入れたものを追跡したり、注文がどのように行われたかを追跡することとは明確に異なります。Catalog というコンテキストは、商品の詳細を管理したり、販売中の商品を展示したりするのに適しています。

命名は難しいです。システムのグループ化された機能がまだ明確でなく、コンテキスト名を考え出すのに行き詰まった場合、単純に作成するリソースの複数形を使用できます。たとえば、商品を管理するための Products というコンテキストです。アプリケーションが成長し、システムの一部が明らかになってきたら、後からコンテキストの名前をより洗練された名前に変更できます。

cataloがコンテキストを作成するために、mix phx.gen.html を利用します。これは、商品の作成、更新、削除のためのEctoアクセスと、コントローラーやWebインターフェイス用のテンプレートなどのWebファイルを、コンテキストにまとめるコンテキストモジュールを作成するものです。プロジェクトのルートで以下のコマンドを実行してください。

$ mix phx.gen.html Catalog Product products title:string \
description:string price:decimal views:integer

* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/templates/product/edit.html.heex
* creating lib/hello_web/templates/product/form.html.heex
* creating lib/hello_web/templates/product/index.html.heex
* creating lib/hello_web/templates/product/new.html.heex
* creating lib/hello_web/templates/product/show.html.heex
* creating lib/hello_web/views/product_view.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20210201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex

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

    resources "/products", ProductController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

注意:ここでは、eコマースシステムをモデル化するための基本的な方法を説明しています。実際には、このようなシステムをモデル化すると、商品のバリエーション、オプション価格、複数の通貨など、より複雑な関係が生まれます。このガイドではシンプルに説明しますが、基礎を学ぶことで、このような完全なシステムを構築するための確かな出発点となるでしょう。

Phoenixは期待通りにWebファイルを lib/hello_web/ に生成してくれました。また、コンテキストファイルが lib/hello/catalog.ex ファイルの中に生成され、商品スキーマが同名のディレクトリに生成されていることがわかります。lib/hellolib/hello_web の違いに注目してください。商品カタログ機能のパブリックAPIとして機能する Catalog モジュールと、商品データをキャストしてバリデーションするためのEctoスキーマである Catalog.Product 構造体があります。Phoenixはウェブとコンテキストのテストも提供し、Hello.Catalog コンテキストでエンティティを作成するためのテストヘルパーも含まれていますが、これについてはあとで見てみましょう。ひとまず、コンソールの指示にしたがって、lib/hello_web/router.ex にルートを追加してみましょう。

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
+   resources "/products", ProductController
  end

新しいルートができたので、Phoenix は mix ecto.migrate を実行してリポジトリを更新するように促してくれますが、その前に priv/repo/migrations/*_create_products.exs に生成されたマイグレーションに少し手を加える必要があります。

  def change do
    create table(:products) do
      add :title, :string
      add :description, :string
-     add :price, :decimal
+     add :price, :decimal, precision: 15, scale: 6, null: false
-     add :views, :integer
+     add :views, :integer, default: 0, null: false

      timestamps()
    end

price列の精度を15、スケールを6とし、not-null制約を設けました。これにより、通貨を適切な精度で保存し、数学的な操作を行うことができます。次に、viewsにデフォルト値と not-null 制約を追加しました。変更が完了したので、データベースをマイグレートする準備ができました。今すぐ実行しましょう。

$ mix ecto.migrate
14:09:02.260 [info]  == Running 20210201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward

14:09:02.262 [info]  create table products

14:09:02.273 [info]  == Migrated 20210201185747 in 0.0s

生成されたコードに飛びこむ前に、mix phx.server でサーバーを起動し、http://localhost:4000/productsにアクセスしてみましょう。"New Product"のリンクをたどって、何も入力せずに"Save"ボタンをクリックしましょう。次のような出力が表示されるはずです。

Oops, something went wrong! Please check the errors below.

フォームを送信すると、すべてのバリデーションエラーが入力内容に沿って表示されます。いいですね。コンテキストジェネレーターがスキーマフィールドをフォームテンプレートに含めたので、必須入力に対するデフォルトのバリデーションが有効になっていることがわかります。それでは、例となる商品データを入力して、フォームを再送信してみましょう。

Product created successfully.

Title: Metaprogramming Elixir
Description: Write Less Code, Get More Done (and Have Fun!)
Price: 15.000000
Views: 0

"Back"のリンクをたどると、すべての商品のリストが表示され、その中に先ほど作成した商品が含まれているはずです。同様に、このレコードを更新したり削除したりすることもできます。さて、ブラウザでの動作を確認したところで、生成されたコードを見てみましょう。

ジェネレーターで始める

小さな mix phx.gen.html コマンドには驚くべきパンチがありました。カタログに商品を作成したり、更新したり、削除したりするための多くの機能がすぐに利用できます。これは完全な機能を備えたアプリとは程遠いものですが、ジェネレーターは何よりもまず学習ツールであり、実際の機能を作り始めるための出発点であることを忘れないでください。コード生成はすべての問題を解決できるわけではありませんが、Phoenixの入出力を教えてくれたり、アプリケーションを設計する際の適切な考え方を教えてくれます。

まずは、lib/hello_web/controllers/product_controller.ex に生成された ProductController を見てみましょう。

defmodule HelloWeb.ProductController do
  use HelloWeb, :controller

  alias Hello.Catalog
  alias Hello.Catalog.Product

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, "index.html", products: products)
  end

  def new(conn, _params) do
    changeset = Catalog.change_product(%Product{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created successfully.")
        |> redirect(to: Routes.product_path(conn, :show, product))

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

  def show(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    render(conn, "show.html", product: product)
  end
  ...
end

コントローラーがどのように動作するかは コントローラーガイドで説明しましたので、このコードはそれほど驚くべきものではありません。注目すべき点は、コントローラーがどのようにして Catalog コンテキストを呼び出しているかということです。index アクションでは、Catalog.list_products/0 で商品のリストを取得し、create アクションでは、Catalog.create_product/1 で商品を永続化していることがわかります。まだカタログのコンテキストを見ていないので、商品の取得や作成がどのように行われているのかはわかりませんが、そこがポイントです。Phoenixのコントローラーは、大規模なアプリケーションのウェブインターフェイスです。商品がデータベースからどのように取得されるか、ストレージにどのように保存されるかなどの詳細は気にする必要はありません。私たちが気にしているのは、アプリケーションに何らかの作業を実行させることだけです。ビジネスロジックとストレージの詳細は、アプリケーションのウェブ層から切り離されているので、これは素晴らしいことです。後日、商品の取得にSQLクエリではなくフルテキストストレージエンジンを使用することになっても、コントローラーを変更する必要はありません。同様に、チャンネルやMixタスク、CSVデータを取り込む長時間のプロセスなど、アプリケーションの他のインターフェイスからコンテキストコードを再利用できます。

create アクションの場合、商品の作成に成功すると、Phoenix.Controller.put_flash/3 を使って成功メッセージを表示し、ルーターの product_path の詳細ページにリダイレクトします。逆に、Catalog.create_product/1 が失敗した場合は、"new.html" テンプレートをレンダリングして、エラーメッセージを取り出すためにテンプレート用のEctoチェンジセットを渡します。

次に、より深く掘り下げて、lib/hello/catalog.exCatalog コンテキストをチェックしてみましょう。

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

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

  alias Hello.Catalog.Product

  @doc """
  Returns the list of products.

  ## Examples

      iex> list_products()
      [%Product{}, ...]

  """
  def list_products do
    Repo.all(Product)
  end
  ...
end

このモジュールは、我々のシステムにおけるすべての商品カタログ機能のためのパブリックAPIとなります。たとえば、商品の詳細管理に加えて、商品カテゴリの分類や、オプションのサイズやトリムなどの商品バリエーションを扱うこともできます。list_products/0 関数を見ると、商品の取得に関するプライベートな詳細を見ることができます。これは非常にシンプルです。 Repo.all(Product) の呼び出しがあります。EctoガイドでEctoのクエリがどのように動作するかを見たので、この呼び出しは見覚えがあるはずです。list_products 関数は、コードの意図、つまり商品をリストアップすることを指定する一般化された関数名です。Repoを使ってPostgreSQLデータベースから商品を取得するという意図の詳細は、呼び出し元には隠されています。これは、Phoenixジェネレーターを使用する際に繰り返し見られる共通のテーマです。Phoenixは、アプリケーションのどこに異なる責任があるのかを考えるように促してくれます。そして、コードの意図を明確にし、詳細をカプセル化するために、それらの異なる領域を適切な名前のモジュールや関数でまとめるのです。

データがどのように取得されるかはわかりましたが、商品はどのように保存されるのでしょうか?それでは、Catalog.create_product/1 関数を見てみましょう。

  @doc """
  Creates a product.

  ## Examples

      iex> create_product(%{field: value})
      {:ok, %Product{}}

      iex> create_product(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

ここではコードよりもドキュメントの方が多いのですが、いくつかの重要な点を紹介します。まず、Ecto Repoがデータベースアクセスのために使われていることが再確認できます。また、Product.changeset/2 の呼び出しにもお気づきでしょう。チェンジセットについては以前にもお話しましたが、今回は私たちのコンテキストでチェンジセットが使われているのを見てみましょう。

lib/hello/catalog/product.ex にある Product スキーマを開くと、すぐに見覚えがあるでしょう。

defmodule Hello.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  schema "products" do
    field :description, :string
    field :price, :decimal
    field :title, :string
    field :views, :integer

    timestamps()
  end

  @doc false
  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :description, :price, :views])
    |> validate_required([:title, :description, :price, :views])
  end
end

これは、以前に mix phx.gen.schema を実行したときに見たものと同じです。ただし、ここでは changeset/2 関数の上に @doc false があります。これは、この関数がパブリックコール可能であるにもかかわらず、パブリックコンテキストAPIの一部ではないことを示しています。チェンジセットを作成する呼び出し元は、コンテキストAPIを介して行います。たとえば、Catalog.create_product/1 は、ユーザーの入力からチェンジセットを構築するために、私たちの Product.changeset/2 を呼び出します。コントローラーのアクションのような呼び出し元は、Product.changeset/2 に直接アクセスしません。商品のチェンジセットとのやりとりはすべて、パブリックな Catalog コンテキストを通じて行われます。

カタログ機能を追加する

これまで見てきたように、コンテキストモジュールは、関連する機能を公開したり、まとめたりする専用モジュールです。Phoenix は list_productsupdate_product といった一般的な関数を生成しますが、これらはビジネスロジックやアプリケーションを成長させるための基盤としての役割しかありません。ここでは、カタログの基本的な機能の1つである、商品ページの閲覧数のトラッキング機能を追加してみましょう。

どんなeコマースシステムでも、商品ページが何回閲覧されたかを追跡する機能は、マーケティング、サジェスト、ランキングなどのために不可欠です。既存の Catalog.update_product 関数を使って、Catalog.update_product(product, %{views: product.views + 1}) のようにすることもできますが、これは競合状態に陥りやすいだけでなく、呼び出し側がカタログシステムについてあまりにも多くのことを知る必要があります。競合状態が発生する理由を確認するために、考えられるイベントの実行について説明します。

直感的には、次のようなイベントを想定します。

  1. ユーザー1が13のカウントで商品ページを読み込む
  2. ユーザー1が商品ページを保存し、カウントが14になる
  3. ユーザー2が14のカウントで商品ページを読み込む
  4. ユーザー2が15のカウントで商品ページを読み込む

実際にはこのようなことが起こります。

  1. ユーザー1が13のカウントで商品ページを読み込む
  2. ユーザー2が13のカウントで商品ページを読み込む
  3. ユーザー1が商品ページを保存し、カウントが14になる
  4. ユーザー2が商品ページを保存し、カウントが14になる

この方法は複数の呼び出し元が古いビューの値を更新する可能性があるため、競合状態が発生し、既存のテーブルを更新するための信頼性の低い方法となります。もっと良い方法があります。

私たちが達成したいことを記述した関数を考えてみましょう。これをどのように使うかというと

product = Catalog.inc_page_views(product)

素晴らしいですね。呼び出し側はこの関数が何をしているのか混乱しないでしょうし、競合状態を防ぐためにアトミックな操作でインクリメントをまとめることができます。

カタログコンテキスト(lib/hello/catalog.ex)を開いて、この新しい関数を追加してください。

  def inc_page_views(%Product{} = product) do
    {1, [%Product{views: views}]} =
      from(p in Product, where: p.id == ^product.id, select: [:views])
      |> Repo.update_all(inc: [views: 1])

    put_in(product.views, views)
  end

ここでは、Repo.update_all に渡すIDを使って、現在の商品を取得するクエリを作成しました。Ectoの Repo.update_all は、データベースに対して一括して更新を行うことができ、ビュー数の増加のようなアトミックな値の更新に最適です。このレポ操作の結果は、更新されたレコードの数と、select オプションで指定された選択されたスキーマの値を返します。新しい商品のビューを受け取ったら、put_in(product.views, views) を使って、新しいビューカウントを商品構造体の中に配置します。

コンテキスト関数が完成したので、これをコントローラーで使用してみましょう。lib/hello_web/controllers/product_controller.exshow アクションを更新して、新しい関数を呼び出します。

  def show(conn, %{"id" => id}) do
    product =
      id
      |> Catalog.get_product!()
      |> Catalog.inc_page_views()

    render(conn, "show.html", product: product)
  end

show アクションを修正して、取得した商品を Catalog.inc_page_views/1 にパイプして、更新された商品を返すようにしました。そして、前と同じようにテンプレートをレンダリングしました。では、実際に試してみましょう。商品ページを何度か更新して、ビュー数が増えるのを見てみましょう。

ectoのデバッグログでも、アトミックアップデートの動作を確認できます。

[debug] QUERY OK source="products" db=0.5ms idle=834.5ms
UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views" [1, 1]

よくできました!

これまで見てきたように、コンテキストを考慮して設計することで、アプリケーションを成長させるための強固な基盤を得ることができます。システムの意図を明らかにする個別の明確なAPIを使用することで、再利用可能なコードを含む保守性の高いアプリケーションを書くことができます。コンテキストAPIを拡張する方法がわかったところで、コンテキスト内のリレーションを扱う方法を探ってみましょう。

コンテキスト内のリレーションシップ

基本的なカタログ機能もいいのですが、さらに進化させるために、商品をカテゴリー別に分けてみましょう。多くのeコマースソリューションでは、商品をさまざまな方法で分類できます。たとえば、ある商品をファッション用、電動工具用などにマークできます。商品とカテゴリーを1対1の関係で始めてしまうと、あとで複数のカテゴリーをサポートする必要が出てきたときに、コードの大幅な変更が必要になります。最初は商品ごとに1つのカテゴリーを追跡し、後に機能が増えても簡単にサポートできるように、カテゴリーの関連付けを設定してみましょう。

今のところ、カテゴリーはテキスト情報のみを含みます。最初にやるべきことは、アプリケーションの中でカテゴリーをどこに置くかを決めることです。ここには商品の展示を管理する「カタログ」コンテキストがあります。商品のカテゴリー化はここに自然にフィットします。Phoenixは既存のコンテキストの中でコードを生成することもできるので、コンテキストに新しいリソースを追加するのも簡単です。プロジェクトのルートで次のコマンドを実行します。

2つのリソースが同じコンテキストに属しているかどうかを判断するのが難しい場合があります。そのような場合には、リソースごとに異なるコンテキストを用意し、必要に応じて後からリファクタリングを行います。そうしないと、緩やかに関連したエンティティの大きなコンテキストになってしまうことがあります。また、2つのリソースが関連しているからといって、必ずしも同じコンテキストに属しているとは限らないことも覚えておいてください。そうでなければ、アプリケーション内のリソースの大部分は互いに接続されているため、すぐに1つの大きなコンテキストになってしまいます。結論として、確信が持てない場合は、リソース間に明示的なモジュール(コンテキスト)を設けるべきです。

$ mix phx.gen.context Catalog Category categories \
title:string:unique

You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/catalog/category.ex
* creating priv/repo/migrations/20210203192325_create_categories.exs
* injecting lib/hello/catalog.ex
* injecting test/hello/catalog_test.exs
* injecting test/support/fixtures/catalog_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

今回は mix phx.gen.context を使用しました。これは mix phx.gen.html と同じですが、ウェブファイルを生成しないことが特徴です。商品を管理するためのコントローラーとテンプレートはすでに用意されているので、新しいカテゴリー機能を既存のウェブフォームと商品詳細ページに統合できます。lib/hello/catalog/category.ex にある商品スキーマと一緒に、新しい Category スキーマがあることがわかります。また、Phoenixはカテゴリー機能のために、既存のCatalogコンテキストに新しい関数を注入していることを教えてくれました。注入された関数は、product関数と非常によく似ていて、create_categorylist_categories などの新しい関数があります。マイグレーションを実行する前に、2つめのコード生成を行う必要があります。カテゴリースキーマは、システム内の個々のカテゴリーを表現するには最適ですが、商品とカテゴリーの間の多対多の関係をサポートする必要があります。幸いなことに、ectoでは中間テーブルを使って簡単にこれを行うことができますので、ecto.gen.migration コマンドでこれを生成してみましょう。

$ mix ecto.gen.migration create_product_categories

* creating priv/repo/migrations/20210203192958_create_product_categories.exs

次に、新しいマイグレーションファイルを開き、change 関数に以下のコードを追加しましょう。


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

  def change do
    create table(:product_categories, primary_key: false) do
      add :product_id, references(:products, on_delete: :delete_all)
      add :category_id, references(:categories, on_delete: :delete_all)
    end

    create index(:product_categories, [:product_id])
    create index(:product_categories, [:category_id])
    create unique_index(:product_categories, [:product_id, :category_id])
  end
end

次に、:product_categories テーブルを作成し、primary_key: false オプションを使用しました。これは、中間テーブルに主キーが必要ないからです。次に、:product_id:category_id の外部キーフィールドを定義し、on_delete: :delete_all を渡して、リンクしている商品やカテゴリが削除された場合に、データベースが中間テーブルのレコードを確実に削除するようにしました。データベース制約を使用することで、その場しのぎでエラーが発生しやすいアプリケーションロジックに頼ることなく、データベースレベルでデータの整合性を確保しています。

次に、外部キーにインデックスを作成し、商品が重複したカテゴリーを持たないようにするためのユニークインデックスを作成しました。マイグレーションの準備が整ったら、次は実行しましょう。

$ mix ecto.migrate

18:20:36.489 [info]  == Running 20210222231834 Hello.Repo.Migrations.CreateCategories.change/0 forward

18:20:36.493 [info]  create table categories

18:20:36.508 [info]  create index categories_title_index

18:20:36.512 [info]  == Migrated 20210222231834 in 0.0s

18:20:36.547 [info]  == Running 20210222231930 Hello.Repo.Migrations.CreateProductCategories.change/0 forward

18:20:36.547 [info]  create table product_categories

18:20:36.557 [info]  create index product_categories_product_id_index

18:20:36.558 [info]  create index product_categories_category_id_index

18:20:36.560 [info]  create index product_categories_product_id_category_id_index

18:20:36.562 [info]  == Migrated 20210222231930 in 0.0s

これで Catalog.Product スキーマと、商品とカテゴリーを関連付ける中間テーブルができたので、新機能を繋ぎ合わせる準備がほぼ整いました。その前に、まずWebUIで選択できる実際のカテゴリーが必要です。早速、アプリケーションに新しいカテゴリーを追加してみましょう。以下のコードを、priv/repo/seeds.exs にあるseedsファイルに追加してください。

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books"] do
  {:ok, _} = Hello.Catalog.create_category(%{title: title})
end

単純にカテゴリタイトルのリストを列挙して、カタログコンテキストの生成された create_category/1 関数を使って新しいレコードを永続化します。mix run でseedを実行できます。

$ mix run priv/repo/seeds.exs

[debug] QUERY OK db=3.1ms decode=1.1ms queue=0.7ms idle=2.2ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Home Improvement", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=1.2ms queue=1.3ms idle=12.3ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Power Tools", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=1.1ms queue=1.1ms idle=15.1ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Gardening", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]
[debug] QUERY OK db=2.4ms queue=1.0ms idle=17.6ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Books", ~N[2021-02-03 19:39:53], ~N[2021-02-03 19:39:53]]

完璧ですね。ウェブ層にカテゴリを統合する前に、商品とカテゴリを関連付ける方法をコンテキストに知らせる必要があります。まず、lib/hello/catalog/product.ex を開いて、次のような関連付けを追加します。

+ alias Hello.Catalog.Category

  schema "products" do
    field :description, :string
    field :price, :decimal
    field :title, :string
    field :views, :integer

+   many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete

    timestamps()
  end

Ecto.Schemamany_to_many マクロを使用して、"product_categories" テーブルを通して、商品を複数のカテゴリーに関連付ける方法をEctoに知らせました。また、on_replace: :delete オプションを使用して、カテゴリーを変更する際に既存の結合レコードを削除することを宣言しました。

スキーマの関連付けができたので、商品フォームにカテゴリーの選択を実装できます。そのためには、フロントエンドからのカタログIDのユーザー入力を、多対多の関連付けに変換する必要があります。幸いなことに、スキーマの設定を行なったので、この作業を簡単に行うことができます。カタログコンテキストを開き、次のように変更します。

- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+   Product |> Repo.get(id) |> Repo.preload(:categories)
+ end

  def create_product(attrs \\ %{}) do
    %Product{}
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.insert()
  end

  def update_product(%Product{} = product, attrs) do
    product
-   |> Product.changeset(attrs)
+   |> change_product(attrs)
    |> Repo.update()
  end

  def change_product(%Product{} = product, attrs \\ %{}) do
-   Product.changeset(product, attrs)
+   categories = list_categories_by_id(attrs["category_ids"])

+   product
+   |> Repo.preload(:categories)
+   |> Product.changeset(attrs)
+   |> Ecto.Changeset.put_assoc(:categories, categories)
  end

+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+   Repo.all(from c in Category, where: c.id in ^category_ids)
+ end

まず、Repo.preload を追加して、商品を取得したときにカテゴリーをプリロードするようにしました。これにより、コントローラーやテンプレートなど、カテゴリ情報を利用したい場所で product.categories を参照できるようになります。次に、既存の change_product 関数を呼び出してチェンジセットを生成するように、create_product および update_product 関数を修正しました。change_product の中で、"category_ids" 属性があれば、すべてのカテゴリーを検索する処理を追加しました。そして、カテゴリをプリロードし、Ecto.Changeset.put_assoc を呼び出して、取得したカテゴリをチェンジセットに配置しました。最後に、list_categories_by_id/1 関数を実装しました。これはカテゴリIDにマッチするカテゴリを問い合わせ、"category_ids" 属性がない場合は空のリストを返します。これで、create_productupdate_product 関数が、カテゴリの関連付けがなされたチェンジセットを受け取るようになり、Repoに対して挿入や更新を試みる際に、すぐに利用できるようになりました。

次に、カテゴリー入力を商品フォームに追加して、新機能をウェブに公開しましょう。フォームのテンプレートを整頓するために、新しい関数を書いて、商品のカテゴリ選択入力のレンダリングの詳細をまとめましょう。lib/hello_web/views/product_view.ex にある ProductView を開いて、次のように入力します。

defmodule HelloWeb.ProductView do
  use HelloWeb, :view

  def category_select(f, changeset) do
    existing_ids = changeset |> Ecto.Changeset.get_change(:categories, []) |> Enum.map(& &1.data.id)

    category_opts =
      for cat <- Hello.Catalog.list_categories(),
          do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]

    multiple_select(f, :category_ids, category_opts)
  end
end

新しい category_select/2 関数を追加しました。この関数は Phoenix.HTMLmultiple_select/3 を使用して、複数の選択タグを生成します。チェンジセットから既存のカテゴリーIDを計算して、入力タグの選択オプションを生成するときにその値を使用しました。これは、すべてのカテゴリーを列挙し、適切な key, value, selected の値を返すことで行いました。チェンジセットに含まれるカテゴリーIDの中にそのカテゴリーIDが含まれていれば、そのオプションは選択されたものとしてマークされます。

これで category_select 関数ができたので、lib/hello_web/templates/product/form.html.heex を開いて追加します。

  ...
  <%= label f, :views %>
  <%= number_input f, :views %>
  <%= error_tag f, :views %>

+ <%= category_select f, @changeset %>

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

保存ボタンの上に category_select を追加しました。では、実際に試してみましょう。次に、商品のshowテンプレートで、商品のカテゴリーを表示してみましょう。以下のコードを lib/hello_web/templates/product/show.html.heex に追加します。

  ...
+ <li>
+   <strong>Categories:</strong>
+   <%= for cat <- @product.categories do %>
+     <%= cat.title %>
+     <br>
+   <% end %>
+ </li>
</ul>

ここで、mix phx.server でサーバーを起動し、http://localhost:4000/products/newにアクセスすると、新しいカテゴリの複数選択入力が表示されます。有効な商品情報を入力し、カテゴリーを1つまたは2つ選択して、「保存」をクリックします。

Title: Elixir Flashcards
Description: Flash card set for the Elixir programming language
Price: 5.000000
Views: 0
Categories:
Education
Books

まだまだ見栄えはしませんが、ちゃんと動いています。コンテキスト内にリレーションシップを追加し、データベースによってデータの整合性を確保しました。悪くないですね。引き続き構築していきましょう!

コンテキスト間の依存

商品カタログの機能ができたので、アプリケーションのもう1つの主な機能である、カタログからの商品のカートインに取り掛かりましょう。ユーザーがカートに入れた商品を適切に追跡するためには、カートに入れた時点での価格などの商品情報と一緒に、この情報を保存する新しい場所が必要です。これは、将来的に商品価格の変更を検出するために必要です。何を作るべきかはわかりましたが、次はカート機能をアプリケーションのどこに置くかを決めなければなりません。

一歩下がって、アプリケーションを切り離して考えてみると、カタログでの商品の展示は、ユーザーのカート管理の責任とは明らかに異なります。商品カタログは、ショッピングカートシステムのルールを気にするべきではありませんし、その逆もまた然りです。新しいカートの責任を処理するために、別のコンテキストが必要なのは明らかです。これを ShoppingCart と呼びましょう。

基本的なカートの責務を処理するために、ShoppingCart コンテキストを作成しましょう。コードを書く前に、次のような機能要件があることを想像してみましょう。

  1. 商品ページからユーザーのカートに商品を入れる
  2. カートに入れた時点での商品価格情報の保存
  3. カート内の数量の保存と更新
  4. カートの価格の合計を計算して表示する

説明を読むと、ユーザーのカートを保存するための Cart リソースと、カート内の商品を追跡するための CartItem が必要であることがわかります。計画を立てたら、さっそく実行してみましょう。次のコマンドを実行して、新しいコンテキストを生成します。

$ mix phx.gen.context ShoppingCart Cart carts user_uuid:uuid:unique

* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20210205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex

Some of the generated database columns are unique. Please provide
unique implementations for the following fixture function(s) in
test/support/fixtures/shopping_cart_fixtures.ex:

    def unique_cart_user_uuid do
      raise "implement the logic to generate a unique cart user_uuid"
    end

Remember to update your repository by running migrations:

    $ mix ecto.migrate

新しいコンテキスト ShoppingCart を生成し、新しい ShoppingCart.Cart スキーマでユーザーとカートアイテムを保持するカートを結びつけました。まだ実際のユーザーを持っていないので、今のところカートは匿名ユーザーの UUID でトラッキングされます。カートの準備ができたので、カートアイテムを生成してみましょう。

$ mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer

You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20210205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

ShoppingCart の中に CartItem という名前の新しいリソースを生成しました。このスキーマとテーブルは、カートと商品への参照と、商品をカートに追加したときの価格、ユーザーが購入を希望する数量を保持します。生成されたマイグレーションファイル priv/repo/migrations/*_create_cart_items.ex を少し変更しましょう。

    create table(:cart_items) do
-     add :price_when_carted, :decimal
+     add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
      add :quantity, :integer
-     add :cart_id, references(:carts, on_delete: :nothing)
+     add :cart_id, references(:carts, on_delete: :delete_all)
-     add :product_id, references(:products, on_delete: :nothing)
+     add :product_id, references(:products, on_delete: :delete_all)

      timestamps()
    end

    create index(:cart_items, [:cart_id])
    create index(:cart_items, [:product_id])
+   create unique_index(:cart_items, [:cart_id, :product_id])

データの整合性を確保するために、再び :delete_all 戦略を使用しました。こうすることで、カートや商品がアプリケーションから削除されても、ShoppingCartCatalog のコンテキストにあるアプリケーションコードに頼らずに、レコードのクリーンアップを行うことができます。これにより、アプリケーションコードは切り離され、データの整合性を保つためにはデータベースが必要となります。また、重複した商品がカートに追加されないように、ユニーク制約を追加しました。これでデータベースのテーブルができたので、あとはマイグレーションを実行するだけです。

$ mix ecto.migrate

16:59:51.941 [info]  == Running 20210205203342 Hello.Repo.Migrations.CreateCarts.change/0 forward

16:59:51.945 [info]  create table carts

16:59:51.949 [info]  create index carts_user_uuid_index

16:59:51.952 [info]  == Migrated 20210205203342 in 0.0s

16:59:51.988 [info]  == Running 20210205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward

16:59:51.988 [info]  create table cart_items

16:59:51.998 [info]  create index cart_items_cart_id_index

16:59:52.000 [info]  create index cart_items_product_id_index

16:59:52.001 [info]  create index cart_items_cart_id_product_id_index

16:59:52.002 [info]  == Migrated 20210205213410 in 0.0s

データベースは新しい cartscart_items テーブルで準備が整いましたが、今度はそれをアプリケーションコードにマッピングする必要があります。データベースの外部キーを異なるテーブルに混在させるにはどうすればいいのか、それが孤立した機能とグループ化された機能というコンテキストパターンにどう関係するのか、疑問に思うかもしれません。ここでは、それぞれのアプローチとそのトレードオフについて説明します。

コンテキストをまたぐデータ

ソフトウェアの依存性は避けられないことが多いのですが、可能な限り依存性を制限し、依存性が必要な場合にはメンテナンスの負担を軽減するように最善を尽くすことができます。これまでのところ、アプリケーションの2つの主要なコンテキストをお互いに分離することに成功しましたが、今度は必要な依存関係を処理することになりました。

私たちの Catalog.Product リソースは、カタログ内の商品を表現する責任を果たす役割を果たしていますが、最終的にカート内にアイテムが存在するためには、カタログ内の商品が存在していなければなりません。このことから、ShoppingCart コンテキストは Catalog コンテキストにデータを依存していることになります。この点を考慮すると、2つの選択肢があります。1つは Catalog コンテキストでAPIを公開して、ShoppingCart システムで使用するために商品データを効率的に取得すること、もう1つはデータベースの結合を使用して依存するデータを取得することです。トレードオフやアプリケーションの規模を考えると、どちらも有効な選択肢ですが、ハードデータの依存関係がある場合にデータベースからデータを結合することは、大規模なアプリケーションのクラスではちょうど良いと思います。さらに、自分でデータを結合すると、アプリケーションコードが複雑になり、パフォーマンスが低下し、データの不整合が発生する可能性があります。また、後になって結合されたコンテキストをまったく別のアプリケーションやデータベースに分離することにしても、分離のメリットは得られます。というのも、パブリック・コンテキストのAPIは変更されない可能性が高いからです。

データの依存関係がどこにあるかわかったので、スキーマの関連付けを行い、ショッピングカートのアイテムを商品に結びつけられるようにしましょう。まず、lib/hello/shopping_cart/cart.ex にあるカートのスキーマを簡単に変更して、カートとそのアイテムを関連付けましょう。

  schema "carts" do
    field :user_uuid, Ecto.UUID

+   has_many :items, Hello.ShoppingCart.CartItem

    timestamps()
  end

これで、カートに入れるアイテムが関連付けられました。lib/hello/shopping_cart/cart_item.ex の中で、カートのアイテムの関連付けを設定してみましょう。

  schema "cart_items" do
-   field :cart_id, :id
-   field :product_id, :id
    field :price_when_carted, :decimal
    field :quantity, :integer

+   belongs_to :cart, Hello.ShoppingCart.Cart
+   belongs_to :product, Hello.Catalog.Product

    timestamps()
  end

  @doc false
  def changeset(cart_item, attrs) do
    cart_item
    |> cast(attrs, [:price_when_carted, :quantity])
    |> validate_required([:price_when_carted, :quantity])
+   |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
  end

まず、cart_id フィールドを、ShoppingCart.Cart スキーマを指す標準的な belongs_to で置き換えました。次に、product_id フィールドを置き換えて、Catalog.Product スキーマを指す belongs_to という最初のコンテキストをまたぐデータ依存関係を追加しました。ここではデータの境界を意図的に結合していますが、これはまさに私たちが必要としているものを提供しているからです。我々のシステムで商品を参照するために必要な最低限の知識を持つ独立したコンテキストAPIです。次に、チェンジセットに新しいバリデーションを追加しました。validate_number/3 では、ユーザーの入力によって提供された数量が0から100の間であることを確認します。

スキーマが完成したので、新しいデータ構造と ShoppingCart のコンテキストAPIをウェブ向けの機能に統合できます。

ショッピングカート機能の追加

先に述べたように、コンテキストジェネレーターは、アプリケーションの出発点に過ぎません。コンテキストの目標を達成するために、しっかりとした名前の、目的を持った関数を書くことができますし、書くべきです。実装すべき新しい機能がいくつかあります。まず、アプリケーションのすべてのユーザーに、カートがまだ存在しない場合は、カートが付与されるようにする必要があります。そこから、ユーザーがアイテムをカートに追加したり、アイテムの数量を更新したり、カートの合計を計算したりできるようにします。さあ、始めましょう。

ここでは、実際のユーザー認証システムに焦点を当てませんが、それが終わるころには、ここで書いたものと自然に統合できるようになっているでしょう。現在のユーザーセッションをシミュレートするために、lib/hello_web/router.ex を開いて、次のように入力します。

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {HelloWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
+   plug :fetch_current_user
+   plug :fetch_current_cart
  end

+ defp fetch_current_user(conn, _) do
+   if user_uuid = get_session(conn, :current_uuid) do
+     assign(conn, :current_uuid, user_uuid)
+   else
+     new_uuid = Ecto.UUID.generate()
+
+     conn
+     |> assign(:current_uuid, new_uuid)
+     |> put_session(:current_uuid, new_uuid)
+   end
+ end

+ alias Hello.ShoppingCart
+
+ def fetch_current_cart(conn, _opts) do
+   if cart = ShoppingCart.get_cart_by_user_uuid(conn.assigns.current_uuid) do
+     assign(conn, :cart, cart)
+   else
+     {:ok, new_cart} = ShoppingCart.create_cart(conn.assigns.current_uuid)
+     assign(conn, :cart, new_cart)
+   end
+ end

すべてのブラウザベースのリクエストで実行するために、新しい :fetch_current_user:fetch_current_cart プラグをブラウザのパイプラインに追加しました。次に、fetch_current_user プラグを実装しました。これは、以前に追加されたユーザーUUIDがあるかどうかセッションをチェックするだけのものです。見つかった場合は、コネクションに current_uuid を追加して完了です。この訪問者をまだ特定していない場合は、Ecto.UUID.generate() でユニークなUUIDを生成し、その値を current_uuid の割り当てに、今後のリクエストでこの訪問者を特定するための新しいセッション値と一緒に入れておきます。ランダムでユニークなIDは、ユーザーを表現するには不十分ですが、リクエスト間で訪問者を追跡して識別するには十分ですので、今はこれで十分です。後にアプリケーションの完成度が上がってくると、完全なユーザー認証ソリューションに移行する準備が整います。現在のユーザーが保証されたので、次に fetch_current_cart プラグを実装しました。このプラグは、ユーザーのUUIDに対応するカートを見つけるか、現在のユーザーに対応するカートを作成し、その結果をコネクションへ割り当てます。ShoppingCart.get_cart_by_user_uuid/1 を実装して、create cart関数をUUIDを受け付けるように変更する必要がありますが、まずはルートを追加しましょう。

カートの表示、数量の更新、チェックアウトプロセスの開始などのカート操作を行うカートコントローラーと、個々のアイテムをカートに追加したり、カートから削除したりするカートアイテムコントローラーを実装する必要があります。以下のルートを lib/hello_web/router.ex にあるルーターに追加します。

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/products", ProductController

+   resources "/cart_items", CartItemController, only: [:create, :delete]

+   get "/cart", CartController, :show
+   put "/cart", CartController, :update
  end

CartItemController のための resources 宣言を追加しました。この宣言は、個々のカートアイテムを追加するためのcreateアクション、削除するためのdeleteアクションをルートに追加します。次に、CartController を指す2つの新しいルートを追加しました。1つ目のルートはGETリクエストで、カートの中身を表示するshowアクションにマッピングされます。2つ目のルートはPUTリクエストで、カートの数量を更新するためのフォームの送信を処理します。

ルートができたところで、商品紹介ページから商品をカートに追加する機能を追加しましょう。新しいファイルを lib/hello_web/controllers/cart_item_controller.ex に作成します。

defmodule HelloWeb.CartItemController do
  use HelloWeb, :controller

  alias Hello.{ShoppingCart, Catalog}

  def create(conn, %{"product_id" => product_id}) do
    product = Catalog.get_product!(product_id)

    case ShoppingCart.add_item_to_cart(conn.assigns.cart, product) do
      {:ok, _item} ->
        conn
        |> put_flash(:info, "Item added to your cart")
        |> redirect(to: Routes.cart_path(conn, :show))

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error adding the item to your cart")
        |> redirect(to: Routes.cart_path(conn, :show))
    end
  end

  def delete(conn, %{"id" => product_id}) do
    {:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.cart, product_id)
    redirect(conn, to: Routes.cart_path(conn, :show))
  end
end

ルーターで宣言したcreateとdeleteのアクションを持つ新しい CartItemController を定義しました。create では、まずカタログ内の商品を Catalog.get_product!/1 で検索し、次に ShoppingCart.add_item_to_cart/2 関数を呼び出します。この関数はあとで実装します。成功した場合は成功した旨のフラッシュメッセージを表示して、カートの詳細ページにリダイレクトします。そうでない場合はフラッシュエラーメッセージを表示して、カートの詳細ページにリダイレクトします。delete では、ShoppingCart コンテキストに実装する remove_item_from_cart 関数を呼び出して、カートの詳細ページにリダイレクトします。これらの2つのショッピングカート関数はまだ実装していませんが、その名前がいかにその意図を表しているかに注目してください。add_item_to_cartremove_item_from_cart` で、ここで何をしようとしているのかが一目瞭然です。また、実装の詳細を一度に考えることなく、WebレイヤーやコンテキストAPIの仕様を決めることができます。

それでは、ShoppingCart コンテキストAPIの新しいインターフェイスを lib/hello/shopping_cart.ex に実装してみましょう。

  alias Hello.Catalog
  alias Hello.ShoppingCart.{Cart, CartItem}

  def get_cart_by_user_uuid(user_uuid) do
    Repo.one(
      from(c in Cart,
        where: c.user_uuid == ^user_uuid,
        left_join: i in assoc(c, :items),
        left_join: p in assoc(i, :product),
        order_by: [asc: i.inserted_at],
        preload: [items: {i, product: p}]
      )
    )
  end

- def create_cart(attrs \\ %{}) do
-   %Cart{}
-   |> Cart.changeset(%{})
+ def create_cart(user_uuid) do
+   %Cart{user_uuid: user_uuid}
+   |> Cart.changeset(%{})
    |> Repo.insert()
+   |> case do
+     {:ok, cart} -> {:ok, reload_cart(cart)}
+     {:error, changeset} -> {:error, changeset}
+   end
  end

  defp reload_cart(%Cart{} = cart), do: get_cart_by_user_uuid(cart.user_uuid)

  def add_item_to_cart(%Cart{} = cart, %Catalog.Product{} = product) do
    %CartItem{quantity: 1, price_when_carted: product.price}
    |> CartItem.changeset(%{})
    |> Ecto.Changeset.put_assoc(:cart, cart)
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Repo.insert(
      on_conflict: [inc: [quantity: 1]],
      conflict_target: [:cart_id, :product_id]
    )
  end

  def remove_item_from_cart(%Cart{} = cart, product_id) do
    {1, _} =
      Repo.delete_all(
        from(i in CartItem,
          where: i.cart_id == ^cart.id,
          where: i.product_id == ^product_id
        )
      )

    {:ok, reload_cart(cart)}
  end

まず、get_cart_by_user_uuid/1 を実装しました。これは、カートを取得して、カートのアイテムとその商品を結合し、事前にロードされたデータがすべてカートにセットされるようにします。次に、create_cart 関数を修正して、属性の代わりにユーザーのUUIDを受け取るようにし、user_uuid フィールドに追加しました。挿入が成功すると、プライベートの reload_cart/1 関数を呼び出してカートの内容を再読み込みします。この関数は単に get_cart_by_user_uuid/1 を呼び出してデータを再取得します。次に、新しい add_item_to_cart/2 関数を作成しました。この関数は、カタログから cart 構造体と product 構造体を受け取ります。この関数は、カタログからカート構造体と商品構造体を受け取ります。レポに対してアップサート操作を行い、新しいカートアイテムをデータベースに挿入するか、すでにカートに存在する場合は数量を1つ増やすようにしました。これは on_conflictconflict_target オプションによって実現されており、挿入の競合をどのように処理するかをレポに伝えます。次に、remove_item_from_cart/2 を実装しました。ここでは、商品IDに一致するカートの商品を削除するためのクエリを含む Repo.delete_all コールを単純に発行します。最後に、reload_cart/1 を呼び出してカートの中身を再読み込みします。

新しいカート機能ができたので、商品カタログの詳細ページで「カートに入れる」ボタンを表示できるようにしましょう。lib/hello_web/templates/product/show.html.heex でテンプレートを開き、次のように変更します。

<h1>Show Product</h1>

+<%= link "Add to cart",
+  to: Routes.cart_item_path(@conn, :create, product_id: @product.id),
+  method: :post %>
...

Phoenix.HTMLlink 関数には :method オプションがあり、クリックするとデフォルトのGETリクエストではなく、HTTP動詞を発行します。このリンクを設置すると、「Add to cart」リンクはPOSTリクエストを発行し、routerで定義したルートがマッチして、CartItemController.create/2 関数にディスパッチされます。

では、実際に試してみましょう。サーバーを mix phx.server で起動し、商品ページにアクセスしてみましょう。カートに追加リンクをクリックしようとすると、エラーページが表示され、コンソールには以下のようなログが表示されます。

[info] POST /cart_items
[debug] Processing with HelloWeb.CartItemController.create/2
  Parameters: %{"_method" => "post", "product_id" => "1", ...}
  Pipelines: [:browser]
INSERT INTO "cart_items" ...
[info] Sent 302 in 24ms
[info] GET /cart
[debug] Processing with HelloWeb.CartController.show/2
  Parameters: %{}
  Pipelines: [:browser]
[debug] QUERY OK source="carts" db=1.9ms idle=1798.5ms

[error] #PID<0.856.0> running HelloWeb.Endpoint (connection #PID<0.841.0>, stream id 5) terminated
Server: localhost:4000 (http)
Request: GET /cart
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function HelloWeb.CartController.init/1 is undefined
       (module HelloWeb.CartController is not available)
       ...

一応動いています!ログをたどると、/cart_items パスにPOSTしたことがわかります。次に、ShoppingCart.add_item_to_cart 関数が cart_items テーブルへの行の挿入に成功し、/cart へのリダイレクトを発行していることがわかります。また、エラーが発生する前に carts テーブルへのクエリが表示されていますが、これは現在のユーザーのカートを取得していることを意味しています。ここまでは順調です。CartItem コントローラーと新しい ShoppingCart コンテキスト関数がそれぞれの役割を果たしていることはわかりましたが、ルーターが存在しないカートコントローラーにディスパッチしようとしたときに、次の未実装の機能にぶつかってしまいました。ユーザーのカートを表示・管理するカートコントローラー、ビュー、テンプレートを作成してみましょう。

新しいファイルを lib/hello_web/controllers/cart_controller.ex に作成し、次のように入力します。

defmodule HelloWeb.CartController do
  use HelloWeb, :controller

  alias Hello.ShoppingCart

  def show(conn, _params) do
    render(conn, "show.html", changeset: ShoppingCart.change_cart(conn.assigns.cart))
  end
end

新しいカートコントローラーを定義して、get "/cart" ルートを処理するようにしました。カートを表示するために、あとで作成する "show.html" テンプレートをレンダリングします。数量の更新によってカートのアイテムを変更できるようにする必要があるので、カートのチェンジセットが必要であることはすぐにわかります。幸いなことに、コンテキストジェネレーターには ShoppingChart.change_cart/1 関数が含まれているので、これを使用します。ルーターで定義した fetch_current_cart プラグのおかげで、コネクションのアサインにすでに含まれているカート構造体を渡します。

次に、ビューとテンプレートを実装します。新しいビューファイルを lib/hello_web/views/cart_view.ex に以下の内容で作成します。

defmodule HelloWeb.CartView do
  use HelloWeb, :view

  alias Hello.ShoppingCart

  def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
end

show.html テンプレートをレンダリングするビューを作成し、ShoppingCart コンテキストをエイリアスして、テンプレートのスコープに入るようにしました。ビューでは商品の価格やカートの合計など、カートの金額を表示する必要があります。そこでDecimalの構造体を受け取り、表示のために適切に丸めて、米ドルのドル記号を前に付ける currency_to_str/1 を定義しました。

次に、テンプレートを lib/hello_web/templates/cart/show.html.heex に作成します。

<h1>My Cart</h1>

<%= if @cart.items == [] do %>
  Your cart is empty
<% else %>
  <%= form_for @changeset, Routes.cart_path(@conn, :update), fn f -> %>
    <ul>
      <%= for item_form <- inputs_for(f, :items), item = item_form.data do %>
        <li>
          <%= hidden_inputs_for(item_form) %>
          <%= item.product.title %>
          <%= number_input item_form, :quantity %>
          <%= currency_to_str(ShoppingCart.total_item_price(item)) %>
        </li>
      <% end %>
    </ul>

    <%= submit "update cart" %>
  <% end %>

  <b>Total</b>: <%= currency_to_str(ShoppingCart.total_cart_price(@cart)) %>
<% end %>

まず、プリロードされた cart.items が空であれば、空のカートというメッセージを表示します。アイテムがある場合には、[form_for] を使用して、CartController.show/2 アクションで割り当てたカートのチェンジセットを取得し、カートコントローラーの update/2 アクションに対応するフォームを作成します。フォームの中では、Phoenix.HTML.Form.inputs_for を使用して、入れ子になったカートアイテムの入力をレンダリングします。各アイテムのフォームの入力に対しては、hidden_inputs_for/1 を使用して、アイテムのIDを隠れたinputタグとしてレンダリングします。これにより、フォームが送信されたときに、アイテムの入力を元に戻すことができるようになります。次に、カートに入っている商品の商品タイトルを表示し、続いて商品の数量を数字で入力します。最後に商品価格を文字列に変換してアイテムフォームを完成させます。ここではまだ ShoppingCart.total_item_price/1 関数を記述していませんが、ここでもコンテキストのための明確で説明的なパブリックインターフェイスの考え方を採用しています。すべてのカートアイテムの入力をレンダリングした後、"カートを更新"の送信ボタンを表示し、カート全体の合計金額を表示しています。これはもう1つの新しい関数 ShoppingCart.total_cart_price/1 で実現されていますが、これはすぐに実装します。

カートページを試してみる準備はほとんどできていますが、まずは通貨の計算関数を実装する必要があります。ショッピングカートのコンテキストを lib/hello/shopping_cart.ex で開き、これらの新しい関数を追加します。

  def total_item_price(%CartItem{} = item) do
    Decimal.mult(item.product.price, item.quantity)
  end

  def total_cart_price(%Cart{} = cart) do
    Enum.reduce(cart.items, 0, fn item, acc ->
      item
      |> total_item_price()
      |> Decimal.add(acc)
    end)
  end

私たちは %CartItem{} 構造体を受け取る total_item_price/1 を実装しました。総額を計算するには、プリロードされた商品の価格を取得し、それに商品の数量を乗じるだけです。Decimal.mult/2 を使用して、Decimalの通貨構造体を取得し、適切な精度で乗算しています。同様に、カートの合計価格を計算するために、total_cart_price/1 関数を実装しました。この関数は、カートを受け取り、カート内のアイテムのプリロードされた商品価格を合計します。ここでも、Decimal 関数を使用して、Decimalの構造体を加算します。

これで価格の合計が計算できるようになりましたので、実際に試してみましょう。http://localhost:4000/cartにアクセスすると、すでにカートに最初の商品が入っているはずです。同じ商品に戻って「カートに入れる」をクリックすると、アップサートが実行されます。数量が2になっているはずです。うまく動いています!

カートページはほぼ完成していますが、フォームを送信するとまた別のエラーが発生します。

Request: POST /cart
** (exit) an exception was raised:
    ** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or private

それでは、lib/hello_web/controllers/cart_controller.ex にある CartController に戻って、updateアクションを実装してみましょう。

  def update(conn, %{"cart" => cart_params}) do
    case ShoppingCart.update_cart(conn.assigns.cart, cart_params) do
      {:ok, cart} ->
        redirect(conn, to: Routes.cart_path(conn, :show))

      {:error, _changeset} ->
        conn
        |> put_flash(:error, "There was an error updating your cart")
        |> redirect(to: Routes.cart_path(conn, :show))
    end
  end

まず、フォームの送信からカートのパラメーターを抽出しました。次に、コンテキストジェネレーターによって追加された既存の ShoppingCart.update_cart/2 関数を呼び出します。この関数にはいくつか変更を加える必要がありますが、インターフェイスはこのままで問題ありません。更新が成功した場合はカートページにリダイレクトし、そうでない場合はフラッシュのエラーメッセージを表示して、間違いを修正するためにユーザーをカートページに戻します。初期設定では、ShoppingCart.update_cart/2 関数は、カートのパラメーターをチェンジセットにキャストし、私たちのレポに対してそれを更新することだけを考えていました。今回の目的のためには、ネストされたカートアイテムの関連付けを処理する必要があります。また、もっとも重要なのは、数量ゼロのアイテムがカートから削除されるような、数量の更新を処理する方法のビジネスロジックです。

lib/hello/shopping_cart.ex のショッピングカートのコンテキストに戻り、update_cart/2 関数を以下の実装で置き換えます。

  def update_cart(%Cart{} = cart, attrs) do
    changeset =
      cart
      |> Cart.changeset(attrs)
      |> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)

    Ecto.Multi.new()
    |> Ecto.Multi.update(:cart, changeset)
    |> Ecto.Multi.delete_all(:discarded_items, fn %{cart: cart} ->
      from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{cart: cart}} -> {:ok, cart}
      {:error, :cart, changeset, _changes_so_far} -> {:error, changeset}
    end
  end

インストール直後と同じコードから始めました。カート構造体を受け取り、ユーザーの入力をカートのチェンジセットにキャストします。ただし、今回は Ecto.Changeset.cast_assoc/3 を使用して、ネストされたアイテムデータを CartItem チェンジセットにキャストしています。カートフォームのテンプレートにある hidden_inputs_for/1 呼び出しを覚えていますか?この隠されたIDデータは、Ectoの cast_assoc がアイテムデータをカート内の既存のアイテムの関連付けにマッピングすることを可能にします。次に、見たことがないかもしれませんが、Ecto.Multi.new/0 を使います。Ectoの Multi は、データベースのレポトランザクション内で最終的に実行される名前付きのオペレーションのチェーンをレイジーに定義することができる機能です。マルチチェーンの各操作は、前のステップから値を受け取り、ステップが失敗するまで実行されます。操作が失敗すると、トランザクションはロールバックされてエラーが返され、そうでなければトランザクションはコミットされます。

今回のマルチオペレーションでは、まず、:cart と名付けたカートの更新を発行します。カートの更新が発行された後、マルチの delete_all オペレーションを実行します。これは更新されたカートを受け取り、数量がゼロの場合のロジックを適用します。カートの中の数量がゼロのアイテムは、数量が空のカートアイテムをすべて見つけるectoクエリを返すことで、すべて消去します。マルチで Repo.transaction/1 を呼び出すと、新しいトランザクションで操作が実行され、元の関数と同様に成功または失敗の結果を呼び出し元に返します。

それでは、ブラウザに戻って実際に試してみましょう。いくつかの商品をカートに入れ、数量を更新すると、価格の計算に伴って値が変化するのを確認できます。数量を0にすると、その商品は削除されます。なかなかいい感じですね。

Ordersコンテキストを追加する

zennの仕様上、1ページでの文字数制限に達するため、contextドキュメントを分割しています。後半はこちら