🫐

ActiveRecord と Ecto の比較①: before_save コールバック

2024/12/27に公開

本稿は、ActiveRecord と Ecto を比較するシリーズの第 1 回です。

ActiveRecord は Ruby on Rails の一部を構成するライブラリです。Ecto は Elixir のライブラリです。いずれもデータベースを操作するために利用します。

今回は、ActiveRecord の特徴的なクラスメソッドである before_save について簡単に解説した後、同等のことを Ecto で実現するにはどうするのか、という話をします。


前提条件として、データベースに users テーブルがあり、これは email という名前の文字列型のカラムがあるとします。

次の Ruby コードをご覧ください。

class User < ApplicationRecord
  before_save :downcase_email

  private
    def downcase_email
      self.email.downcase!
    end
end

User モデルクラスを定義しています。プライベートメソッド downcase_email が定義されていて、このメソッドを before_save クラスメソッドでコールバックとして指定しています。「コールバック」とは、ある処理の途中で呼び出されるメソッドのことです。before_save コールバックは、users テーブルに対するレコードの挿入または更新が行われる直前に呼び出されます。

使用例は次のようになります。

u = User.new
u.assign_attributes(email: "ALICE@EXAMPLE.COM")
u.save

この結果、users テーブルに挿入されたレコードの email カラムの値は "alice@example.com" となります。

同等のことを Ecto で行うには、関数 Ecto.Changeset.prepare_changes/2 を利用します。

次の Elixir コードをご覧ください。

defmodule User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> prepare_changes(&downcase_email/1)
  end

  defp downcase_email(changeset) do
    email = get_field(changeset, :email)
    put_change(changeset, :email, String.downcase(email))
  end
end

使用例は次のようになります。

{:ok, u} =
  %User{}
  |> User.changeset(%{email: "ALICE@EXAMPLE.COM"})
  |> MyApp.Repo.insert()

さて、ソースコードを比較すると Elixir 版の方が Ruby 版よりもかなり長くなっています。総じて Ecto は ActiveRecord よりも明示的です。ActiveRecord はシステム起動時にデータベース管理システムにテーブル定義の情報を問い合わせることで、モデルクラスに emailemail= などのメソッドを暗黙的に定義しますが、Ecto では関数 schema/2 を用いて、スキーマ構造体が持つフィールドを列挙する必要があります。

他にも ActiveRecord と Ecto の間には大きな違いがあります。ActiveRecord では、モデルクラスのインスタンスが次の2つの情報を持ちます。

  • データベースから取得したテーブルレコードの元データ
  • そのテーブルレコードに対して加えたい変更

しかし、Ecto においては「スキーマ構造体」が前者を表現し、「チェンジセット構造体」が後者を表現します。

次に示す Elixir 版のソースコードの抜粋をご覧ください。

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> prepare_changes(&downcase_email/1)
  end

User モジュールの関数 changeset/2 は、2 つの引数を取ります。第 1 引数は User スキーマ構造体、第 2 引数は %{email: "ALICE@EXAMPLE.COM"} のようなマップ(Ruby のハッシュに相当)です。この関数からの戻り値は、チェンジセット構造体です。

パイプ演算子 |> を使わずに 2 〜 3 行目を書き換えると cast(user, attrs, [:email]) のようになります。ここでは、マップ attrs からキー :email とその値だけを抜き出して user に対して適用し、チェンジセットを作ります。Rails における Strong Parameters の役割の一部を関数 cast/4 が担っています。


さて、ここからが本題です。

Ecto.Changeset モジュールの関数 prepare_changes/2 は第 1 引数にチェンジセット構造体、第 2 引数に無名関数(匿名関数)を受け取って、チェンジセット構造体を返します。

引数部分の &downcase_email/1 で使われている & は「キャプチャ演算子」と呼ばれ、後続の名前を持つ有名の関数を無名関数に変換します。スラッシュ記号(/)の右にある 1 は、関数のアリティ(引数の個数)を示します。

プライベート関数 downcase_email/1 は次のように定義されています。

defp downcase_email(changeset) do
  email = get_field(changeset, :email)
  put_change(changeset, :email, String.downcase(email))
end

この関数は引数にチェンジセット構造体を受け取って、チェンジセット構造体を返します。関数 get_field/3 はチェンジセットからフィールドの値を抜き出して返します。さきほどの使用例を実行したとすれば、変数 email には "ALICE@EXAMPLE.COM" がセットされます。

関数 String.downcase/2 は文字列を受け取って、そこに含まれるアルファベットをすべて小文字に変換して返します。

そして、関数 put_change/3 は、チェンジセット構造体に新たな変更点を登録します。ここでは、:email フィールドに "alice@example.com" という変更をセットします。

関数 prepare_changes/2 によってチェンジセット構造体に登録された無名関数は、データベースに対して挿入、更新、または削除を行う場合に、操作の直前に実行されます。削除操作の前にも実行される点で ActiveRecord の before_save コールバックとは異なります。


もし、挿入と更新の場合だけ :email フィールドに変更を加えたいのであれば、次のように書けます。

defp downcase_email(changeset) do
  if changeset.action in [:insert, :update] do
    email = get_field(changeset, :email)
    put_change(changeset, :email, String.downcase(email))
  else
    changeset
  end
end

MyApp.Repo モジュールの関数によりデータベース操作が行われるとき、チェンジセット構造体の action フィールドには、操作の種類を示すアトム(:insert:update:delete、など)がセットされていますので、その値によって処理を分岐できます。

しかし、削除の直前に :email フィールドに変更を加えたとしても、余分な更新処理が実行されるわけではないので無害です。ここまで書く必要はないでしょう。


以上の解説を読んで ActiveRecord の方が簡単でいいな、と思われた方もいらっしゃるでしょう。

実際のところ、ActiveRecord の方が入門のハードルが低い点は否めないです。

けれども、Web アプリケーションの開発が進んでソースコードが複雑になってくると、Ecto の明瞭さが効いてきます。次回はその辺りを深堀したいと思います。

Discussion