You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Rails中使用ActiveModel::attribute处理jsonb嵌套属性的正确方式

问题背景与遇到的问题

我在模型中使用jsonb列,结构如下:

{
  name: 'abc',
  points: [{
   x: 0,
   y: 2,
  }, {
   x: 2,
   y: 3,
  }],
}

我希望使用嵌套的ActiveModel::Attribute方法对数据进行(反)序列化,以便能对该JSON使用ActiveModel验证。我尝试了如下实现:

ParentModel 代码

# app/models/parent_model.rb
class ParentModel < ApplicationRecord
  attribute :child, ChildModel.new
end

ChildModel 代码

# app/models/child_model.rb
class ChildModel < ActiveModel::Type::Value
  include ActiveModel::Model

  attr_accessor :name

  attribute :points, Point.new, array: true

  def cast(value)
    case value
    when String
      decoded = ActiveSupport::JSON.decode(value) rescue nil
      ChildModel.new(decoded) unless decoded.nil?
    when Hash
      ChildModel.new(value)
    when ChildModel
      value
    end
  end

  def serialize(value)
    value&.attributes&.to_json
  end

  def changed_in_place?(raw_old_value, new_value)
    deserialize(raw_old_value) != new_value
  end
end

Point 代码

# app/models/point.rb
class Point < ActiveModel::Type::Value
  include ActiveModel::Model

  attr_accessor :x, :y

  def cast(value)
    case value
    when String
      decoded = ActiveSupport::JSON.decode(value) rescue nil
      Point.new(decoded) unless decoded.nil?
    when Hash
      Point.new(value)
    when Point
      value
    end
  end

  def serialize(value)
    value&.attributes&.to_json
  end

  def changed_in_place?(raw_old_value, new_value)
    deserialize(raw_old_value) != new_value
  end
end

但遇到了两个问题:

  • Point和ChildModel存在limit、precision、scale属性,它们似乎来自ActiveModel::Type::Value,是否应该使用其他基类?
  • 即使在实例化ParentModel时设置了child属性,它仍会被保存为nil到数据库中。

请问在Rails中实现该需求的正确方式是什么?


解决方案

问题1:基类选择问题

ActiveModel::Type::Value是为数据库字段类型设计的,自带limit、precision、scale这类数据库列属性,完全不适合用来做嵌套JSON对象的业务模型。你应该直接基于ActiveModel::Model构建嵌套模型,再配合自定义类型处理序列化逻辑。

问题2:保存为nil的原因

你的自定义类型实现存在两处核心问题:一是serialize返回JSON字符串,但jsonb列需要的是Hash对象(Rails会自动处理Hash到JSON的序列化);二是cast和deserialize方法没有正确覆盖类型转换逻辑,导致Rails无法识别有效数据,最终保存为nil。

正确实现步骤

1. 定义嵌套业务模型(Point和ChildModel)

直接基于ActiveModel::Model构建,专注业务逻辑与验证,避免继承数据库类型带来的冗余属性:

# app/models/point.rb
class Point
  include ActiveModel::Model
  attr_accessor :x, :y

  # 添加业务验证
  validates :x, :y, presence: true, numericality: true

  # 转换为Hash用于序列化
  def to_h
    { x: x, y: y }
  end

  # 从Hash构建实例
  def self.from_h(hash)
    return nil unless hash.is_a?(Hash)
    new(x: hash[:x], y: hash[:y])
  end
end
# app/models/child_model.rb
class ChildModel
  include ActiveModel::Model
  attr_accessor :name, :points

  # 添加业务验证
  validates :name, presence: true
  validates :points, presence: true

  # 初始化时自动将Hash数组转为Point实例
  def initialize(attributes = {})
    super
    self.points = (points || []).map { |p| Point.from_h(p) }.compact
  end

  # 转换为Hash用于序列化
  def to_h
    { name: name, points: points.map(&:to_h) }
  end

  # 从Hash构建实例
  def self.from_h(hash)
    return nil unless hash.is_a?(Hash)
    new(name: hash[:name], points: hash[:points])
  end
end

2. 自定义类型处理jsonb列转换

创建继承自ActiveRecord::Type::Json的自定义类型,复用Rails原生JSON处理逻辑,仅需覆盖实例与Hash的转换方法:

# app/types/child_type.rb
class ChildType < ActiveRecord::Type::Json
  # 将Hash/JSON字符串转为ChildModel实例
  def cast(value)
    case value
    when ChildModel
      value
    when Hash, String
      ChildModel.from_h(super(value))
    else
      nil
    end
  end

  # 将ChildModel实例转为Hash,供Rails序列化为JSON
  def serialize(value)
    if value.is_a?(ChildModel)
      super(value.to_h)
    else
      super(value)
    end
  end
end

3. 父模型中注册并使用自定义类型

# app/models/parent_model.rb
class ParentModel < ApplicationRecord
  # 注册自定义类型
  ActiveRecord::Type.register(:child_type, ChildType)

  # 绑定child列到自定义类型
  attribute :child, :child_type

  # 触发嵌套模型的验证,将错误合并到父模型
  validate do
    next unless child.present?
    next if child.valid?

    child.errors.each do |attr, msg|
      errors.add("child.#{attr}", msg)
    end
  end
end

4. 使用示例

# 创建实例
parent = ParentModel.new(
  child: {
    name: "abc",
    points: [{ x: 0, y: 2 }, { x: 2, y: 3 }]
  }
)

# 验证(会触发ChildModel和Point的所有验证)
parent.valid?

# 保存(child会被正确序列化为jsonb存储)
parent.save!

# 查询时自动转为ChildModel实例
parent = ParentModel.last
parent.child.name # => "abc"
parent.child.points.first.x # => 0

内容的提问来源于stack exchange,提问作者Johannes Stricker

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 22:53:14