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
相关产品推荐
相关产品推荐

