Learning
VOL. XIII · NO. 17 · Elixir · 01 JAN 1970

Ecto Schema 深潜:prefix / 复合主键 / Changeset

Elixir 编程 · 01 JAN 1970 · 6 min read · 1,256 words
· · ·

Ecto 的 schema 不仅是字段映射:prefix 决定表落在哪个 PostgreSQL schema,复合主键需要显式声明,changeset 才是真正的"写入网关"。

本课目标
学完后,你应该能解释 Ecto Schema 在数据库连接、查询、写入三处扮演的角色;正确处理 prefix(多租户 / schema 隔离);声明复合主键;用 changeset 写出可校验、可审计、可回滚的业务写入。

一、Ecto 的三层结构

Ecto 在应用代码与数据库之间提供三层抽象:

  • Repo:连接池 + 事务、真正的 SQL 执行入口(MyApp.Repo)。
  • Schema:把数据库行映射成 Elixir 结构体,是字段、类型、关联的”模型”。
  • Changeset:写入前的验证、转换和约束容器,是真正的”业务写入网关”。
defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
end

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :age, :integer
    has_many :orders, MyApp.Orders.Order
    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :age])
    |> validate_required([:email])
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
  end
end

MyApp.Repo.insert(%MyApp.Accounts.User{} |> MyApp.Accounts.User.changeset(%{email: "a@b.c", age: 30}))

二、schema/2 宏做了什么

use Ecto.Schema 会注入:

  • struct 定义:用 schema "table_name" 声明对应数据库表。
  • field / belongs_to / has_many:定义字段、关联。
  • __schema__ 反射 API:__schema__(:source)__schema__(:fields)
  • Ecto.Type 行为:通过 field :balance, :decimal 注册的类型在 cast 时自动转换。

field 第三个参数支持类型修饰和默认值:

field :status, :string, default: "active"
field :balance, :decimal, precision: 18, scale: 4
field :meta, :map, default: %{}
field :avatar, :string, redact: true  # 日志中会被遮蔽
提示
生产环境调试时,redact: true 可避免日志打印手机号、token 等敏感字段。

三、prefix:把表放进 PostgreSQL schema

PostgreSQL 自身支持 schema 命名空间。Ecto 的 prefix 直接对应 SET search_path

defmodule MyApp.Tenants.Tenant do
  use Ecto.Schema

  @primary_key {:id, :binary_id, autogenerate: true}
  schema "tenants", prefix: "tenants_meta" do
    field :name, :string
    field :slug, :string
  end
end

上面的 tenants 表会被写到 tenants_meta.tenants。这是多租户隔离最干净的写法:

  • 物理隔离:每个租户独立 PostgreSQL schema,便于备份、迁移、权限控制。
  • 透明使用:Repo 仍只调 MyApp.Repoprefix 由 schema 决定,调用方无需关心。

也可以在查询级别临时指定:

MyApp.Repo.all(from u in User, prefix: "tenant_42")

还可以使用 query prefix 给整个查询加 prefix:

from o in Order, prefix: ^tenant_slug, where: o.id == ^id
常见误区
不要把 prefix 和 MySQL 的"database"混淆。PostgreSQL 的 schema 是命名空间,多个 schema 可以共享同一个 database,多个 database 才会真的拆库。

四、复合主键:业务边界经常不是单一字段

Ecto 默认 :id 是主键,但订单流水、订阅关系、用户—角色关联表经常需要复合主键:

defmodule MyApp.Billing.Subscription do
  use Ecto.Schema

  @primary_key false
  schema "subscriptions" do
    field :user_id, :integer
    field :plan_id, :integer
    field :started_at, :naive_datetime
    field :status, :string

    belongs_to :user, MyApp.Accounts.User
    belongs_to :plan, MyApp.Billing.Plan
  end

  def changeset(sub, attrs) do
    sub
    |> cast(attrs, [:user_id, :plan_id, :started_at, :status])
    |> validate_required([:user_id, :plan_id])
    |> unique_constraint([:user_id, :plan_id], name: :subscriptions_user_id_plan_id_index)
  end
end

关键点:

  • @primary_key false 关闭默认主键。
  • 复合唯一约束通过 unique_constraint([:user_id, :plan_id], name: ...) 在 changeset 层引用,对应数据库索引名。
  • 如果用 assoc 函数而不是 belongs_to,可以把外键自动作为主键的一部分。
@primary_key {:user_id, :integer, autogenerate: false}
@foreign_key_type :integer

schema "user_roles", primary_key: false do
  belongs_to :user, MyApp.Accounts.User, primary_key: true
  belongs_to :role, MyApp.Billing.Role, primary_key: true
  field :granted_at, :naive_datetime
end
实战陷阱
复合主键的 update 行为依赖底层数据库:PostgreSQL 上必须给出完整主键才能定位行;如果只想更新一列,用普通唯一索引 + 业务 ID 更安全。

五、Changeset:不是表单验证那么简单

很多人以为 changeset 只做表单校验。实际上它是所有写入路径的必经之地:

def update_status(user, attrs) do
  user
  |> User.changeset(attrs)
  |> put_change(:status, "active")
  |> Repo.update()
end

三层职责:

  1. cast:从外部 attrs(map)过滤允许写入的字段。安全防御的关键。
  2. validate_*:纯函数式校验,不触发 IO,可独立单元测试。
  3. unique_constraint / foreign_key_constraint:依赖数据库约束,是真正的”最终防线”。

六、自定义类型与 embed

Ecto.Type 可以承载业务特定值。例如金钱:

defmodule Money do
  use Ecto.Type

  def type, do: :map

  def cast(%Money{currency: c, amount: a}) when is_integer(a), do: {:ok, %{currency: c, amount: a}}
  def cast(_), do: :error

  def load(%{"currency" => c, "amount" => a}), do: {:ok, %Money{currency: c, amount: a}}
  def dump(%{currency: c, amount: a}), do: {:ok, %{"currency" => c, "amount" => a}}
  def dump(_), do; :error
end

field :price, Money

对于嵌入式对象,可以用 embeds_one / embeds_many

embeds_one :address, MyApp.Accounts.Address
embeds_many :tags, MyApp.Accounts.Tag

cast_embed(:address, with: &MyApp.Accounts.Address.changeset/2)
cast_embed(:tags, with: &MyApp.Accounts.Tag.changeset/2)

七、生产写入的边界

  • 永远 cast 白名单cast(attrs, ~w(email name)a),不要 cast(attrs, Map.keys(attrs))
  • 复合约束使用 changeset 函数unique_constraint([:tenant_id, :slug]) 而非只在数据库层面加索引。
  • 敏感字段 redactfield :phone, :string, redact: true,避免日志泄露。
  • 关联加载用 preloadRepo.preload([:orders, :team]),避免 N+1。
  • changeset 不要 IO:保持纯函数,把 IO 放进 Repo 调用之后。
记忆口诀
Schema 描述"字段长什么样",changeset 描述"字段应该怎么写"。把这两个概念分清楚,是 Ecto 工程化的起点。

八、测验

1

Ecto 的三层结构是?

2

schema "users", prefix: "tenant_a" 的作用是?

3

复合主键应该用哪个宏?

4

cast(attrs, [:email]) 的真正安全意义是?

5

复合唯一约束如何在 changeset 里表达?

6

field :token, :string, redact: true 的作用是?

7

changeset 的 validate_* 函数应该?

8

embeds_onehas_one 的区别是?

9

查询级别 prefix 主要解决?

10

下面哪种写法不应出现?

**下一步:**掌握 Schema / Changeset 后,下一课看 Ecto.Query:什么时候用 query DSL,什么时候必须 raw SQL。