-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WIP: Restore legacy ams::model behavior #1998
Changes from all commits
f508ecd
c1e99d1
d27d63b
a9710db
fefa1f7
ea87892
22a7cf7
ffcc3e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,33 +1,78 @@ | ||
# ActiveModelSerializers::Model is a convenient | ||
# serializable class to inherit from when making | ||
# serializable non-activerecord objects. | ||
# ActiveModelSerializers::Model is a convenient superclass when making serializable non-activerecord objects. | ||
# It also serves as documentation of an implementation that satisfies ActiveModel::Serializer::Lint::Tests. | ||
module ActiveModelSerializers | ||
class Model | ||
include ActiveModel::Serializers::JSON | ||
include ActiveModel::Model | ||
|
||
class_attribute :attribute_names | ||
# @method :attributes | ||
# - unset attributes will appear in nil values in the attributes hash | ||
# - instance attributes can ONLY be changed by accessor methods. | ||
# - the return value of the attributes method is now frozen. since it is always derived, trying to mutate it is strongly discouraged :) | ||
# | ||
# @method :attributes_are_always_the_initialization_data | ||
# | ||
# When true, the deprecate attribute behavior is mixed in. | ||
# Defaults to +true+, in order to avoid breaking changes with older versions of this class which lacked defined attributes. | ||
# | ||
# However, new apps probably want to change the default to false, and old apps want to create a special new model subclass where it is false for use in new models. | ||
# | ||
# Specifically, the deprecated behavior this re-adds is: | ||
# - the attributes hash returned by `#attributes` will always be the value of the attributes passed in at initialization, | ||
# regardless of what attr_* methods were defined. | ||
# - unset attributes, rather than being nil, will not be included in the attributes hash, even when an `attr_accessor` exists. | ||
# - the only way to change the change the attributes after initialization is to mutate the `attributes` directly. Accessor methods | ||
# will NOT mutate the attributes. (This is the bug that led to the change). | ||
# | ||
# For now, the Model only supports the notion of 'attributes'. In the tests, we do support 'associations' on the PORO, | ||
# in order to adds accessors for values that should not appear in the attributes hash, as we model associations. | ||
# However, it is not yet clear if it makes sense for a PORO to have associations outside of the tests. | ||
class_attribute :attributes_are_always_the_initialization_data, instance_writer: false, instance_reader: false | ||
self.attributes_are_always_the_initialization_data = true | ||
|
||
def self.inherited(subclass) | ||
if subclass.attributes_are_always_the_initialization_data | ||
unless subclass.included_modules.include?(AttributesAreAlwaysTheInitializationData) | ||
subclass.prepend(AttributesAreAlwaysTheInitializationData) | ||
|
||
deprecated_method = :attributes_are_always_the_initialization_data | ||
target = is_a?(Module) ? "#{self}." : "#{self.class}#" | ||
msg = ["NOTE: #{target}#{deprecated_method} is deprecated with no replacement", | ||
"\n#{target}#{deprecated_method} called from #{ActiveModelSerializers.location_of_caller.join(':')}"] | ||
warn "#{msg.join}." | ||
end | ||
end | ||
super | ||
end | ||
|
||
# @method :attribute_names | ||
# Is only available as a class-method since the ActiveModel::Serialization mixin in Rails | ||
# uses an +attribute_names+ local variable, which may conflict if we were to add instance methods here. | ||
class_attribute :attribute_names, instance_writer: false, instance_reader: false | ||
# Initialize +attribute_names+ for all subclasses. The array is usually | ||
# mutated in the +attributes+ method, but can be set directly, as well. | ||
self.attribute_names = [] | ||
|
||
def self.attributes(*names) | ||
self.attribute_names |= names.map(&:to_sym) | ||
# Silence redefinition of methods warnings | ||
# Silence redefinition of methods warnings, since it is expected. | ||
ActiveModelSerializers.silence_warnings do | ||
attr_accessor(*names) | ||
end | ||
end | ||
|
||
attr_reader :errors | ||
# NOTE that +updated_at+ isn't included in +attribute_names+, | ||
# which means it won't show up in +attributes+ unless a subclass has | ||
# either <tt>attributes :updated_at</tt> which will redefine the methods | ||
# or <tt>attribute_names << :updated_at</tt>. | ||
attr_writer :updated_at | ||
|
||
# NOTE that +id+ will always be in +attributes+. | ||
attributes :id | ||
|
||
# Support for validation and other ActiveModel::Errors | ||
attr_reader :errors | ||
|
||
def initialize(attributes = {}) | ||
@errors = ActiveModel::Errors.new(self) | ||
super | ||
|
@@ -37,7 +82,7 @@ def initialize(attributes = {}) | |
# +attributes+ are returned frozen to prevent any expectations that mutation affects | ||
# the actual values in the model. | ||
def attributes | ||
attribute_names.each_with_object({}) do |attribute_name, result| | ||
self.class.attribute_names.each_with_object({}) do |attribute_name, result| | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed the instance reader/writer since the ActiveModel::Serialization mixin in Rails uses an |
||
result[attribute_name] = public_send(attribute_name).freeze | ||
end.with_indifferent_access.freeze | ||
end | ||
|
@@ -51,7 +96,7 @@ def cache_key | |
].compact) | ||
end | ||
|
||
# When no set, defaults to the time the file was modified. | ||
# When not set, defaults to the time the file was modified. | ||
# See NOTE by attr_writer :updated_at | ||
def updated_at | ||
defined?(@updated_at) ? @updated_at : File.mtime(__FILE__) | ||
|
@@ -67,5 +112,26 @@ def self.lookup_ancestors | |
[self] | ||
end | ||
# :nocov: | ||
|
||
module AttributesAreAlwaysTheInitializationData | ||
def initialize(attributes = {}) | ||
@initialized_attributes = attributes && attributes.symbolize_keys | ||
super | ||
end | ||
|
||
# Defaults to the downcased model name. | ||
# This probably isn't a good default, since it's not a unique instance identifier, | ||
# but that was the old behavior \_('-')_/ | ||
def id | ||
@initialized_attributes.fetch(:id) { self.class.model_name.name && self.class.model_name.name.downcase } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably not a good default, but that was the old behavior. |
||
end | ||
|
||
# The only way to change the attributes of an instance is to directly mutate the attributes. | ||
# | ||
# model.attributes[:foo] = :bar | ||
def attributes | ||
@initialized_attributes | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yup, they can be mutated https://github.com/rails-api/active_model_serializers/pull/1998/files#r93893962 |
||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,14 @@ | |
module ActionController | ||
module Serialization | ||
class AdapterSelectorTest < ActionController::TestCase | ||
Profile = poro_without_legacy_model_support(::Model) do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
attributes :name, :description | ||
associations :comments | ||
end | ||
class ProfileSerializer < ActiveModel::Serializer | ||
type 'profiles' | ||
attributes :name, :description | ||
end | ||
class AdapterSelectorTestController < ActionController::Base | ||
def render_using_default_adapter | ||
@profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1') | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,6 @@ | ||
# The test poro superclass can't have the legacy behavior included because there's no simple way to remove it. | ||
# This lets us turn the legacy behavior on and off in the test context so that we can test it. | ||
ActiveModelSerializers::Model.attributes_are_always_the_initialization_data = false | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see https://github.com/rails-api/active_model_serializers/pull/1998/files#r93893962 but the test poro superclass can't have the legacy behavior included because there's no simple way to remove it. Instead, the test poro superclass has the 'new' behavior, but, subclasses of it, by default, will have the 'old' behavior. Basically, it kind of extends the AMS::Model in the test context so that we can test it with the legacy behavior on and off. |
||
class Model < ActiveModelSerializers::Model | ||
FILE_DIGEST = Digest::MD5.hexdigest(File.open(__FILE__).read) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
module ActiveModelSerializersWithoutLegacyModelSupport | ||
module_function | ||
|
||
def poro_without_legacy_model_support(superklass = ActiveModelSerializers::Model, &block) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An example test helper I derived from one I wrote in some unmerged commits |
||
original_attributes_are_always_the_initialization_data = superklass.attributes_are_always_the_initialization_data | ||
superklass.attributes_are_always_the_initialization_data = false | ||
Class.new(superklass) do | ||
class_exec(&block) if block | ||
end | ||
ensure | ||
superklass.attributes_are_always_the_initialization_data = original_attributes_are_always_the_initialization_data | ||
end | ||
end | ||
Minitest::Test.include ActiveModelSerializersWithoutLegacyModelSupport | ||
Minitest::Test.extend ActiveModelSerializersWithoutLegacyModelSupport |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is curious. Is this changing the default
include Module
functionality to make the module be a kind of superclass to the class using the module?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
prepend
is basically the same asinclude
except it inserts the module below instead of above the class in the inheritance chain, such that the module can override the class' methods.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh ok, I thought it went the other way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@NullVoxPopuli this is just a compatibility layer to avoid a breaking change. I should add some docs around setting it true vs. false and how to do both at same time and why :)