Support Active Model attribute type decoration

This adds a `decorate_attributes` method to Active Model to support
attribute type decoration.  `decorate_attributes` is a private,
low-level API that is intended to be wrapped by high-level APIs like
`ActiveRecord::Base::normalizes` and `ActiveRecord::Base::enum`.
This commit is contained in:
Jonathan Hefner 2022-03-09 16:43:02 -06:00
parent 53438e9216
commit 31bb0b4aee
2 changed files with 136 additions and 25 deletions

@ -10,17 +10,27 @@ module AttributeRegistration # :nodoc:
module ClassMethods # :nodoc:
def attribute(name, type = nil, default: (no_default = true), **options)
name = resolve_attribute_name(name)
type = resolve_type_name(type, **options) if type.is_a?(Symbol)
pending = pending_attribute(name)
pending.type = type if type
pending.default = default unless no_default
pending_attribute_modifications << PendingType.new(name, type) if type || no_default
pending_attribute_modifications << PendingDefault.new(name, default) unless no_default
reset_default_attributes
end
def decorate_attributes(names = nil, &decorator) # :nodoc:
names = names&.map { |name| resolve_attribute_name(name) }
pending_attribute_modifications << PendingDecorator.new(names, decorator)
reset_default_attributes
end
def _default_attributes # :nodoc:
@default_attributes ||= build_default_attributes
@default_attributes ||= AttributeSet.new({}).tap do |attribute_set|
apply_pending_attribute_modifications(attribute_set)
end
end
def attribute_types # :nodoc:
@ -30,33 +40,39 @@ def attribute_types # :nodoc:
end
private
class PendingAttribute # :nodoc:
attr_accessor :type, :default
def apply_to(attribute)
attribute = attribute.with_type(type || attribute.type)
attribute = attribute.with_user_default(default) if defined?(@default)
attribute
PendingType = Struct.new(:name, :type) do # :nodoc:
def apply_to(attribute_set)
attribute = attribute_set[name]
attribute_set[name] = attribute.with_type(type || attribute.type)
end
end
def pending_attribute(name)
@pending_attributes ||= {}
@pending_attributes[resolve_attribute_name(name)] ||= PendingAttribute.new
PendingDefault = Struct.new(:name, :default) do # :nodoc:
def apply_to(attribute_set)
attribute_set[name] = attribute_set[name].with_user_default(default)
end
end
def apply_pending_attributes(attribute_set)
PendingDecorator = Struct.new(:names, :decorator) do # :nodoc:
def apply_to(attribute_set)
(names || attribute_set.keys).each do |name|
attribute = attribute_set[name]
type = decorator.call(name, attribute.type)
attribute_set[name] = attribute.with_type(type) if type
end
end
end
def pending_attribute_modifications
@pending_attribute_modifications ||= []
end
def apply_pending_attribute_modifications(attribute_set)
superclass.send(__method__, attribute_set) if superclass.respond_to?(__method__, true)
defined?(@pending_attributes) && @pending_attributes.each do |name, pending|
attribute_set[name] = pending.apply_to(attribute_set[name])
pending_attribute_modifications.each do |modification|
modification.apply_to(attribute_set)
end
attribute_set
end
def build_default_attributes
apply_pending_attributes(AttributeSet.new({}))
end
def reset_default_attributes

@ -6,9 +6,20 @@ module ActiveModel
class AttributeRegistrationTest < ActiveModel::TestCase
MyType = Class.new(Type::Value)
Type.register(MyType.name.to_sym, MyType)
TYPE_1 = MyType.new(precision: 1)
TYPE_2 = MyType.new(precision: 2)
MyDecorator = DelegateClass(Type::Value) do
attr_reader :name
alias :cast_type :__getobj__
def initialize(name, cast_type)
super(cast_type)
@name = name
end
end
test "attributes can be registered" do
attributes = default_attributes_for { attribute :foo, TYPE_1 }
assert_same TYPE_1, attributes["foo"].type
@ -52,12 +63,12 @@ class AttributeRegistrationTest < ActiveModel::TestCase
assert_not_predicate attributes["bar"], :came_from_user?
end
test "attribute_types reflects registered attribute types" do
test "::attribute_types reflects registered attribute types" do
klass = class_with { attribute :foo, TYPE_1 }
assert_same TYPE_1, klass.attribute_types["foo"]
end
test "attribute_types returns the default type when key is missing" do
test "::attribute_types returns the default type when key is missing" do
klass = class_with { attribute :foo, TYPE_1 }
assert_equal Type::Value.new, klass.attribute_types["bar"]
end
@ -137,6 +148,90 @@ class AttributeRegistrationTest < ActiveModel::TestCase
assert_nil parent._default_attributes["bar"].value
end
test "::decorate_attributes decorates specified attributes" do
attributes = default_attributes_for do
attribute :foo, TYPE_1
attribute :bar, TYPE_2
attribute :qux, TYPE_2
decorate_attributes([:foo, :bar]) { |name, type| MyDecorator.new(name, type) }
end
assert_instance_of MyDecorator, attributes["foo"].type
assert_equal "foo", attributes["foo"].type.name
assert_same TYPE_1, attributes["foo"].type.cast_type
assert_instance_of MyDecorator, attributes["bar"].type
assert_equal "bar", attributes["bar"].type.name
assert_same TYPE_2, attributes["bar"].type.cast_type
assert_same TYPE_2, attributes["qux"].type
end
test "::decorate_attributes decorates all attributes when none are specified" do
attributes = default_attributes_for do
attribute :foo, TYPE_1
attribute :bar, TYPE_2
decorate_attributes { |name, type| MyDecorator.new(name, type) }
end
assert_same TYPE_1, attributes["foo"].type.cast_type
assert_same TYPE_2, attributes["bar"].type.cast_type
end
test "::decorate_attributes supports conditional decoration" do
attributes = default_attributes_for do
attribute :foo, TYPE_1
attribute :bar, TYPE_2
decorate_attributes { |name, type| MyDecorator.new(name, type) if name.match?(/oo/) }
end
assert_same TYPE_1, attributes["foo"].type.cast_type
assert_same TYPE_2, attributes["bar"].type
end
test "::decorate_attributes stacks decorators" do
attributes = default_attributes_for do
attribute :foo, TYPE_1
decorate_attributes { |name, type| MyDecorator.new("#{name}1", type) }
decorate_attributes { |name, type| MyDecorator.new("#{name}2", type) }
end
assert_instance_of MyDecorator, attributes["foo"].type
assert_equal "foo2", attributes["foo"].type.name
assert_instance_of MyDecorator, attributes["foo"].type.cast_type
assert_equal "foo1", attributes["foo"].type.cast_type.name
assert_same TYPE_1, attributes["foo"].type.cast_type.cast_type
end
test "superclass attribute types can be decorated" do
parent = class_with do
attribute :foo, TYPE_1
end
child = class_with(parent) do
decorate_attributes { |name, type| MyDecorator.new(name, type) }
end
assert_instance_of MyDecorator, child._default_attributes["foo"].type
assert_same TYPE_1, child._default_attributes["foo"].type.cast_type
assert_same TYPE_1, parent._default_attributes["foo"].type
end
test "re-registering an attribute overrides previous decorators" do
parent = class_with do
attribute :foo, TYPE_1
decorate_attributes { |name, type| MyDecorator.new(name, type) }
end
child = class_with(parent) do
attribute :foo, TYPE_1
end
assert_same TYPE_1, child._default_attributes["foo"].type
end
private
def class_with(base_class = nil, &block)
Class.new(*base_class) do