ecto-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseEcto Patterns for Phoenix/Elixir
适用于Phoenix/Elixir的Ecto模式
Ecto is the data layer for Phoenix applications: schemas, changesets, queries, migrations, and transactions. Good Ecto practice keeps domain logic in contexts, enforces constraints in the database, and uses transactions for multi-step workflows.
Ecto是Phoenix应用的数据层,负责Schema、Changeset、查询、迁移及事务管理。良好的Ecto实践要求将领域逻辑置于上下文(contexts)中,在数据库层面强制执行约束,并使用事务处理多步骤工作流。
Schemas and Changesets
Schema与Changeset
elixir
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
endGuidelines
- Keep casting/validation in changesets; keep business logic in contexts.
- Always pair validation with DB constraints (,
unique_constraint).foreign_key_constraint - Use for updates; avoid mass assigning without casting.
changeset/2
elixir
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实践准则
- 类型转换与校验逻辑放在Changeset中;业务逻辑置于上下文里。
- 始终将校验规则与数据库约束(、
unique_constraint)配对使用。foreign_key_constraint - 使用处理更新操作;避免未经类型转换的批量赋值。
changeset/2
Migrations
数据库迁移
elixir
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])
endSafe migration tips
- Prefer additive changes: add columns nullable, backfill, then enforce null: false.
- For large tables: use for indexes; disable in
concurrently: trueand wrap inchangefor Postgres.up/down - Data migrations belong in separate modules called from via
mix ecto.migrateor in distinct scripts; ensure idempotence.execute/1 - Coordinate locks: avoid long transactions; break migrations into small steps.
elixir
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: false - 针对大表:创建索引时使用;针对Postgres,需在
concurrently: true中禁用该选项,改用change方法包裹。up/down - 数据迁移应放在独立模块中,通过在
execute/1中调用,或使用单独脚本;确保操作具有幂等性。mix ecto.migrate - 协调锁机制:避免长事务;将迁移拆分为多个小步骤。
Queries and Preloads
查询与预加载
elixir
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: queryPatterns
- Use rather than calling Repo in loops; prefer
preloadafter fetching.Repo.preload/2 - Use to avoid loading large blobs.
select - For concurrency, use with
Repo.transactionin queries that need row-level locks.lock: "FOR UPDATE"
elixir
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 - 使用避免加载大对象数据。
select - 并发场景下,在需要行级锁的查询中,结合与
Repo.transaction。lock: "FOR UPDATE"
Transactions and Ecto.Multi
事务与Ecto.Multi
elixir
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()
endGuidelines
- Prefer for side effects that can fail; return
Multi.run/3or{:ok, value}.{:error, reason} - Use for batch updates; include
Multi.update_allguards to prevent unbounded writes.where - Propagate errors upward; translate them in controllers/LiveViews.
elixir
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 - 向上传递错误;在控制器或LiveViews中转换错误信息。
Associations and Constraints
关联与约束
- Use /
on_replace: :deleteto control nested changes.:nilify - Define and
foreign_key_constraint/3in changesets to surface DB errors cleanly.unique_constraint/3 - For many-to-many, prefer join schema () instead of automatic
has_many :membershipswhen you need metadata.many_to_many
- 使用/
on_replace: :delete控制嵌套变更的行为。:nilify - 在Changeset中定义与
foreign_key_constraint/3,以清晰呈现数据库错误。unique_constraint/3 - 多对多关联场景下,当需要元数据时,优先使用关联Schema()而非自动生成的
has_many :memberships。many_to_many
Pagination and Filtering
分页与过滤
- Offset/limit for small datasets; cursor-based for large lists (,
Scrivener,Flop).Paginator - Normalize filters in contexts; avoid letting controllers build queries directly.
- Add composite indexes to match filter columns; verify with .
EXPLAIN ANALYZE
- 小数据集使用偏移量/限制(offset/limit);大型列表使用基于游标分页(可借助、
Scrivener、Flop工具)。Paginator - 在上下文中统一处理过滤逻辑;避免让控制器直接构建查询语句。
- 为过滤字段添加复合索引;使用验证索引效果。
EXPLAIN ANALYZE
Multi-Tenancy Patterns
多租户模式
- Prefix-based: Postgres schemas per tenant (with
put_source/2) — good isolation, needs per-tenant migrations.prefix: - Row-based: column + row filters — simpler migrations; add partial indexes per tenant when large.
tenant_id - Always scope queries by tenant in contexts; consider using policies/guards to enforce.
- 基于前缀:为每个租户创建独立的Postgres Schema(通过指定
put_source/2参数)——隔离性好,但需要为每个租户执行迁移。prefix: - 基于行:通过列+行级过滤实现——迁移更简单;数据量大时可为每个租户添加部分索引。
tenant_id - 始终在上下文中按租户范围限定查询;可考虑使用策略/守卫机制强制执行。
Performance and Ops
性能与运维
- Use for large exports; wrap in
Repo.stream.Repo.transaction - Cache hot reads with ETS/Cachex; invalidate on writes.
- Watch query counts in LiveView/Channels; preload before rendering to avoid N+1.
- Telemetry: exports query timings; add DB connection pool metrics.
OpentelemetryEcto
- 导出大量数据时使用;需包裹在
Repo.stream中执行。Repo.transaction - 使用ETS/Cachex缓存高频读操作;写入时失效缓存。
- 监控LiveView/Channels中的查询次数;渲染前预加载关联数据以避免N+1查询问题。
- 遥测:可导出查询耗时数据;添加数据库连接池指标监控。
OpentelemetryEcto
Testing
测试
elixir
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- sets up sandboxed DB; keep tests async unless transactions conflict.
DataCase - Use factories/fixtures in to build valid structs quickly.
test/support - For migrations, add regression tests for constraints (unique/index-backed constraints).
elixir
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 - 在中使用工厂/固定数据快速构建合法结构体。
test/support - 针对迁移,为约束(唯一/索引约束)添加回归测试。
Common Pitfalls
常见陷阱
- Running risky DDL in a single migration step (avoid locks; break apart).
- Skipping DB constraints and relying only on changesets.
- Querying associations in loops instead of preloading.
- Missing transactions for multi-step writes (partial state on failure).
- Forgetting tenant scoping on read/write in multi-tenant setups.
- 在单个迁移步骤中执行高风险DDL操作(避免锁;拆分步骤)。
- 仅依赖Changeset而忽略数据库约束。
- 循环查询关联数据而非使用预加载。
- 多步骤写入操作未使用事务(失败时导致数据状态不一致)。
- 多租户场景下读写操作未按租户范围限定。