Ecto Schema 深潜:prefix / 复合主键 / Changeset
Ecto 的 schema 不仅是字段映射:prefix 决定表落在哪个 PostgreSQL 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.Repo,prefix由 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
三层职责:
- cast:从外部 attrs(map)过滤允许写入的字段。安全防御的关键。
- validate_*:纯函数式校验,不触发 IO,可独立单元测试。
- 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])而非只在数据库层面加索引。 - 敏感字段 redact:
field :phone, :string, redact: true,避免日志泄露。 - 关联加载用 preload:
Repo.preload([:orders, :team]),避免 N+1。 - changeset 不要 IO:保持纯函数,把 IO 放进 Repo 调用之后。
八、测验
Ecto 的三层结构是?
schema "users", prefix: "tenant_a" 的作用是?
复合主键应该用哪个宏?
cast(attrs, [:email]) 的真正安全意义是?
复合唯一约束如何在 changeset 里表达?
field :token, :string, redact: true 的作用是?
changeset 的 validate_* 函数应该?
embeds_one 与 has_one 的区别是?
查询级别 prefix 主要解决?
下面哪种写法不应出现?
**下一步:**掌握 Schema / Changeset 后,下一课看 Ecto.Query:什么时候用 query DSL,什么时候必须 raw SQL。