Ecto belongs_to 关联导致 Refund 插入失败
结论
问题出在 belongs_to 的定义方式,不只是 changeset。
在 belongs_to :transaction_id, Payments.Payment 中,:transaction_id 会被 Ecto 视为关联名称。Ecto 可能据此生成外键字段 transaction_id_id,并默认引用 Payment 的主键 id。这与实际需求不符:Refund 应通过字符串字段 transaction_id 关联 Payment.transaction_id。
需要做以下调整:
- 使用实体名称作为关联名称,例如
:payment。 - 用
foreign_key: :transaction_id指定 Refund 表中的外键字段。 - 用
references: :transaction_id指定 Payment 表中被引用的字段。 - 该字段是字符串,因此需要设置
type: :string。 - changeset 的默认参数应为
%{},不能使用[]。 - 将
::transaction_id修正为:transaction_id。
正确的 Schema 配置
Payment
defmodule Payments.Payment do
use Payments.Web, :model
schema "payment" do
field :payment_id, :string
field :captured_amount, :decimal
field :transaction_id, :string
has_many :refunds,
Payments.Refund,
foreign_key: :transaction_id,
references: :transaction_id
timestamps()
end
def changeset(payment, params \\ %{}) do
payment
|> cast(params, [:payment_id, :transaction_id, :captured_amount])
|> validate_required([:payment_id, :transaction_id, :captured_amount])
|> unique_constraint(:transaction_id)
end
end
原代码中的:
validate_required([:payment_id, ::transaction_id, :captured_amount])
应改为:
validate_required([:payment_id, :transaction_id, :captured_amount])
多出的冒号会产生错误的语法或 AST,无法表示普通字段名。
Refund
defmodule Payments.Refund do
use Payments.Web, :model
@primary_key {:refund_id, :string, autogenerate: false}
schema "refund" do
field :state, :string
field :pay_id, :string
field :amount, :decimal
field :create_time, Ecto.DateTime
belongs_to :payment, Payments.Payment,
foreign_key: :transaction_id,
references: :transaction_id,
type: :string
timestamps()
end
def changeset(refund, params \\ %{}) do
refund
|> cast(params, [
:refund_id,
:state,
:transaction_id,
:pay_id,
:amount,
:create_time
])
|> validate_required([
:refund_id,
:transaction_id,
:pay_id,
:amount
])
|> foreign_key_constraint(:transaction_id)
end
end
这里不需要另外定义:
field :transaction_id, :string
belongs_to 会按照 foreign_key: :transaction_id 自动定义该字段。再次定义通常会造成字段冲突。
为什么原来的 belongs_to 有问题
这段代码:
belongs_to :transaction_id, Payments.Payment
并不是让 Ecto 使用 transaction_id 作为外键。第一个参数表示关联名称,Ecto 会根据这个名称推导外键。
使用默认配置时,它大致相当于:
belongs_to :transaction_id, Payments.Payment,
foreign_key: :transaction_id_id,
references: :id
由此会产生三个问题:
- Refund 中的实际字段是
transaction_id,Ecto 却可能查找transaction_id_id。 transaction_id是字符串,而默认关联键通常按整数主键处理。- 需要引用的是
Payment.transaction_id,默认引用的却是Payment.id。
关联名称和外键字段应分别声明:
belongs_to :payment, Payments.Payment,
foreign_key: :transaction_id,
references: :transaction_id,
type: :string
这样既可以通过:
refund.payment
访问关联,也能让 refund.transaction_id 保存实际的外键值。
数据库迁移也必须一致
只修改 Schema 还不够,数据库外键约束也要引用正确的列。
Payment 表的迁移示例:
create table(:payment) do
add :payment_id, :string, null: false
add :captured_amount, :decimal, null: false
add :transaction_id, :string, null: false
timestamps()
end
create unique_index(:payment, [:transaction_id])
Refund 表可以这样创建:
create table(:refund, primary_key: false) do
add :refund_id, :string, primary_key: true
add :state, :string
add :pay_id, :string, null: false
add :amount, :decimal, null: false
add :create_time, :utc_datetime
add :transaction_id,
references(:payment,
column: :transaction_id,
type: :string,
on_delete: :restrict
),
null: false
timestamps()
end
create index(:refund, [:transaction_id])
被外键引用的 Payment.transaction_id 应有唯一约束。否则,同一个 transaction_id 可能对应多条 Payment 记录,关联结果将无法确定。部分数据库也会直接拒绝创建指向非唯一字段的外键。
如果表已经存在,应使用 alter table 调整字段和约束,不要再次执行 create table。
插入 Refund
参数应使用 map,不能使用关键字列表或普通列表:
params = %{
"refund_id" => "refund_001",
"state" => "created",
"transaction_id" => "txn_123",
"pay_id" => "payment_001",
"amount" => Decimal.new("25.50"),
"create_time" => Ecto.DateTime.utc()
}
%Payments.Refund{}
|> Payments.Refund.changeset(params)
|> Payments.Repo.insert()
也可以使用 atom key:
params = %{
refund_id: "refund_001",
state: "created",
transaction_id: "txn_123",
pay_id: "payment_001",
amount: Decimal.new("25.50")
}
插入 Refund 前,必须先存在 transaction_id 对应的 Payment。例如:
%Payments.Payment{}
|> Payments.Payment.changeset(%{
payment_id: "payment_001",
transaction_id: "txn_123",
captured_amount: Decimal.new("100.00")
})
|> Payments.Repo.insert()
随后才能插入引用 "txn_123" 的 Refund。
关于 no function clause matching
原代码中较容易引发这个错误的是:
def changeset(struct, params \\ [])
cast/3 需要 map 形式的参数。调用 changeset(refund) 时,默认值 [] 可能无法匹配 cast/3 所需的函数分支。默认参数应改成:
def changeset(struct, params \\ %{})
如果调用方传入的是关键字列表,需要先转换为 map:
params
|> Enum.into(%{})
|> then(&Payments.Refund.changeset(%Payments.Refund{}, &1))
更合适的做法是从调用源头直接传入 map。
版本注意事项
示例中的 Ecto.DateTime 来自较早版本的 Ecto。较新的 Ecto 项目通常使用:
field :create_time, :utc_datetime
或者:
field :create_time, :naive_datetime
具体类型取决于数据库字段是否需要时区语义。如果项目仍在使用旧版 Ecto,可以暂时保留 Ecto.DateTime,但不要混用新旧版本的日期时间类型。
最后要确保以下四处完全一致:
Payment.transaction_id的类型是:string。Refund.transaction_id的类型是:string。- Schema 使用
references: :transaction_id。 - 数据库外键通过
column: :transaction_id引用同一个字段。
备注:内容仅供参考。
