ecto-patterns について
ecto-patterns は、Phoenix/Elixir 開発者向けの Ecto データレイヤー実践ガイドです。スキーマ、マイグレーション、クエリ、トランザクション、マルチテナンシー、テストを扱い、コンテキストへのドメインロジック配置とデータベース制約を重視します。
主な機能
- 01スキーマと changeset の検証
- 02安全なスキーマ・データ移行
- 03クエリ、プリロード、ページネーション
- 04トランザクションと Ecto.Multi
- 05マルチテナントのデータ分離
- 06データベーステストと性能の指針
活用シーン
- 01Phoenix アプリのデータレイヤー設計
- 02大規模なデータベーステーブルの安全な変更
- 03関連データ取得時の N+1 回避
- 04複数段階の書き込みとテナント別クエリの実装
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill ecto-patternsインストール後、Claude Code で/ecto-patternsを入力して読み込まれたか確認します。
同梱ファイルmetadata.json
詳細
Phoenix/Elixir 向け Ecto のパターン
Ecto は Phoenix アプリケーションのデータレイヤーです。スキーマ、チェンジセット、クエリ、マイグレーション、トランザクションを扱います。Ecto を適切に使うことで、ドメインロジックをコンテキストに置き、データベースで制約を適用し、複数ステップのワークフローでトランザクションを使用できます。
スキーマとチェンジセット
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :hashed_password, :string
field :confirmed_at, :naive_datetime
has_many :memberships, MyApp.Orgs.Membership
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> validate_required([:email, :password])
|> validate_format(:email, ~r/@/)
|> validate_length(:password, min: 12)
|> unique_constraint(:email)
|> hash_password()
end
defp hash_password(%{valid?: true} = cs),
do: put_change(cs, :hashed_password, Argon2.hash_pwd_salt(get_change(cs, :password)))
defp hash_password(cs), do: cs
end
ガイドライン
- キャストとバリデーションはチェンジセットに置き、ビジネスロジックはコンテキストに置きます。
- バリデーションには必ず DB 制約(
unique_constraint、foreign_key_constraint)を組み合わせます。 - 更新には
changeset/2を使い、キャストせずに一括代入することは避けます。
マイグレーション
def change do
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
end
安全なマイグレーションのヒント
- 追加型の変更を優先します。列を NULL 許容で追加し、データをバックフィルしてから
null: falseを適用します。 - 大きなテーブルでは、インデックスに
concurrently: trueを使います。Postgres ではchange内で無効にし、up/downでラップします。 - データマイグレーションは、
execute/1を介してmix ecto.migrateから呼び出す別モジュール、または独立したスクリプトにします。冪等性を確保してください。 - ロックを考慮し、長いトランザクションは避けて、マイグレーションを小さなステップに分けます。
クエリとプリロード
import Ecto.Query
def list_users(opts \\ %{}) do
base =
from u in MyApp.Accounts.User,
preload: [:memberships],
order_by: [desc: u.inserted_at]
Repo.all(apply_pagination(base, opts))
end
defp apply_pagination(query, %{limit: limit, offset: offset}),
do: query |> limit(^limit) |> offset(^offset)
defp apply_pagination(query, _), do: query
パターン
- ループ内で Repo を呼び出すのではなく、
preloadを使います。取得後にRepo.preload/2を使う方法を優先します。 - 大きな blob の読み込みを避けるには
selectを使います。 - 並行処理では、行レベルロックが必要なクエリに
lock: "FOR UPDATE"を指定し、Repo.transactionを使います。
トランザクションと Ecto.Multi
alias Ecto.Multi
def onboard_user(attrs) do
Multi.new()
|> Multi.insert(:user, User.registration_changeset(%User{}, attrs))
|> Multi.insert(:org, fn %{user: user} ->
Org.changeset(%Org{}, %{owner_id: user.id, name: attrs["org_name"]})
end)
|> Multi.run(:welcome, fn _repo, %{user: user} ->
MyApp.Mailer.deliver_welcome(user)
{:ok, :sent}
end)
|> Repo.transaction()
end
ガイドライン
- 失敗する可能性のある副作用には
Multi.run/3を優先します。{:ok, value}または{:error, reason}を返します。 - 一括更新には
Multi.update_allを使い、無制限の書き込みを防ぐためにwhereガードを含めます。 - エラーを上位へ伝播させ、コントローラーや LiveView で変換します。
アソシエーションと制約
- ネストした変更を制御するには、
on_replace: :delete/:nilifyを使います。 - DB エラーを適切に表面化させるため、チェンジセットで
foreign_key_constraint/3とunique_constraint/3を定義します。 - 多対多の関連でメタデータが必要な場合は、自動の
many_to_manyではなく、結合スキーマ(has_many :memberships)を優先します。
ページネーションとフィルタリング
- 小さなデータセットにはオフセット/リミットを、大きなリストにはカーソルベースの方式(
Scrivener、Flop、Paginator)を使います。 - コンテキストでフィルターを正規化し、コントローラーから直接クエリを組み立てないようにします。
- フィルター対象の列に合わせて複合インデックスを追加し、
EXPLAIN ANALYZEで確認します。
マルチテナントのパターン
- プレフィックスベース:テナントごとに Postgres スキーマを使用します(
prefix:を指定したput_source/2)。分離性に優れますが、テナントごとのマイグレーションが必要です。 - 行ベース:
tenant_id列と行フィルターを使用します。マイグレーションはより簡単ですが、大規模な場合はテナントごとに部分インデックスを追加します。 - コンテキストでは常にテナントでクエリをスコープし、適用を徹底するためにポリシーやガードの使用を検討します。
パフォーマンスと運用
- 大規模なエクスポートには
Repo.streamを使い、Repo.transactionでラップします。 - 頻繁に読み取られるデータは ETS/Cachex でキャッシュし、書き込み時に無効化します。
- LiveView/Channels のクエリ数を監視し、N+1 を避けるためにレンダリング前にプリロードします。
- テレメトリー:
OpentelemetryEctoはクエリ時間をエクスポートします。DB 接続プールのメトリクスも追加します。
テスト
use MyApp.DataCase, async: true
test "registration changeset validates email" do
changeset = User.registration_changeset(%User{}, %{email: "bad", password: "secretsecret"})
refute changeset.valid?
assert %{email: ["has invalid format"]} = errors_on(changeset)
end
DataCaseはサンドボックス化された DB をセットアップします。トランザクションが競合しない限り、テストは非同期のままにします。test/supportのファクトリー/フィクスチャを使って、有効な構造体をすばやく構築します。- マイグレーションでは、制約(ユニーク制約/インデックスに基づく制約)の回帰テストを追加します。
よくある落とし穴
- リスクの高い DDL を 1 回のマイグレーションで実行する(ロックを避けるため、分割します)。
- DB 制約を省略し、チェンジセットだけに頼る。
- プリロードせず、ループ内でアソシエーションをクエリする。
- 複数ステップの書き込みでトランザクションを使わない(失敗時に状態が部分的に反映される)。
- マルチテナント構成で、読み取り/書き込み時にテナントのスコープを指定し忘れる。
コメント
Markdown 対応