Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/store_model/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "store_model/enum"
require "store_model/type_builders"
require "store_model/nested_attributes"
require "store_model/unknown_attribute_assignment"

module StoreModel
# When included into class configures it to handle JSON column
Expand All @@ -26,6 +27,9 @@ def self.included(base) # :nodoc:

base.attribute_method_suffix "?"

# Included last so it takes precedence over ActiveModel::AttributeAssignment.
base.include StoreModel::UnknownAttributeAssignment

base.extend(ClassMethods)
end
# rubocop:enable Metrics/MethodLength
Expand Down
8 changes: 2 additions & 6 deletions lib/store_model/types/one.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def type
# @param value [Object] a value to cast
#
# @return StoreModel::Model
def cast_value(value) # rubocop:disable Metrics/MethodLength
def cast_value(value)
return nil if value.nil?

if value.is_a?(String)
Expand All @@ -40,8 +40,6 @@ def cast_value(value) # rubocop:disable Metrics/MethodLength
else
raise_cast_error(value)
end
rescue ActiveModel::UnknownAttributeError => e
handle_unknown_attribute(value, e)
end

# Casts a value from the ruby type to a type that the database knows how
Expand Down Expand Up @@ -93,9 +91,7 @@ def raise_cast_error(value)
end

def model_instance(value)
@model_klass.new(value)
rescue ActiveModel::UnknownAttributeError => e
handle_unknown_attribute(value, e)
StoreModel::UnknownAttributeAssignment.collect_for(@model_klass) { @model_klass.new(value) }
end

def deserialize_by_types(hash)
Expand Down
19 changes: 0 additions & 19 deletions lib/store_model/types/one_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,8 @@ def model_instance(_value)
def decode_and_initialize(value)
decoded = ActiveSupport::JSON.decode(value) rescue nil
model_instance(decoded) unless decoded.nil?
rescue ActiveModel::UnknownAttributeError => e
handle_unknown_attribute(decoded, e)
end
# rubocop:enable Style/RescueModifier

def handle_unknown_attribute(value, exception)
attributes = unwrap_attributes(value.to_h)
key = unknown_attribute_key(attributes, exception.attribute)

cast_value(attributes.except(key)).tap do |configuration|
configuration.unknown_attributes[exception.attribute.to_s] = attributes[key]
end
end

def unwrap_attributes(value)
value.fetch(:attributes) { value.fetch("attributes", value) }
end

def unknown_attribute_key(attributes, attribute)
attributes.key?(attribute.to_s) ? attribute.to_s : attribute.to_sym
end
end
end
end
9 changes: 3 additions & 6 deletions lib/store_model/types/one_polymorphic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def type
# @param value [Object] a value to cast
#
# @return StoreModel::Model
def cast_value(value) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def cast_value(value) # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
return nil if value.nil?
return nil if @union && value.respond_to?(:empty?) && value.empty?

Expand All @@ -44,8 +44,6 @@ def cast_value(value) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCompl
else
raise_cast_error(value)
end
rescue ActiveModel::UnknownAttributeError => e
handle_unknown_attribute(value, e)
end

# Casts a value from the ruby type to a type that the database knows how
Expand Down Expand Up @@ -96,13 +94,12 @@ def decode_and_initialize(value)
return nil if @union && decoded.respond_to?(:empty?) && decoded.empty?

model_instance(decoded)
rescue ActiveModel::UnknownAttributeError => e
handle_unknown_attribute(decoded, e)
end
# rubocop:enable Style/RescueModifier

def model_instance(value)
extract_model_klass(value).new(value)
model_klass = extract_model_klass(value)
StoreModel::UnknownAttributeAssignment.collect_for(model_klass) { model_klass.new(value) }
end
end
end
Expand Down
39 changes: 39 additions & 0 deletions lib/store_model/unknown_attribute_assignment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module StoreModel
# Keeps input keys without a writer in #unknown_attributes while a StoreModel
# type builds the model, instead of raising ActiveModel::UnknownAttributeError.
module UnknownAttributeAssignment
COLLECTING_KEY = :store_model_collecting_unknown_attributes_for

def self.collect_for(model_class)
previous = Thread.current[COLLECTING_KEY]
Thread.current[COLLECTING_KEY] = model_class
yield
ensure
Thread.current[COLLECTING_KEY] = previous
end

def attribute_writer_missing(name, value)
model_class = Thread.current[COLLECTING_KEY]
raise ActiveModel::UnknownAttributeError.new(self, name) unless model_class && is_a?(model_class)

unknown_attributes[name] = value
end

if ActiveModel::VERSION::MAJOR < 8

private

# Backport of ActiveModel 8.0, which added the attribute_writer_missing hook.
def _assign_attribute(key, value)
setter = :"#{key}="
public_send(setter, value)
rescue NoMethodError
raise if respond_to?(setter)

attribute_writer_missing(key.to_s, value)
end
end
end
end
5 changes: 5 additions & 0 deletions spec/store_model/model_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
end

describe "#initialize" do
it "raises for an unknown attribute on direct construction" do
expect { Configuration.new(color: "red", unknown_attribute: "value") }
.to raise_error(ActiveModel::UnknownAttributeError, /unknown_attribute/)
end

context "when symbolized hash is passed" do
subject { Configuration.new(attributes) }

Expand Down
46 changes: 46 additions & 0 deletions spec/store_model/types/one_polymorphic_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,52 @@
end
end

it "selects the polymorphic model from JSON while retaining unknown attributes" do
plain_class = Class.new do
include StoreModel::Model

attribute :kind, :string
end
colored_class = Class.new do
include StoreModel::Model

attribute :kind, :string
attribute :color, :string
end
type = StoreModel.one_of { |json| json["kind"] == "colored" ? colored_class : plain_class }.to_type

configuration = type.cast_value({ kind: "colored", color: "red", extra: "kept" }.to_json)

expect(configuration).to be_a(colored_class)
expect(configuration).to have_attributes(kind: "colored", color: "red")
expect(configuration.unknown_attributes).to eq("extra" => "kept")
expect(configuration.as_json).to include("extra" => "kept")
end

it "selects the same model from wrapped JSON and Hash values" do
plain_class = Class.new do
include StoreModel::Model

attribute :kind, :string
end
wrapped_class = Class.new do
include StoreModel::Model

attribute :kind, :string
attribute :color, :string
end
type = described_class.new(proc { |attributes| attributes.key?("attributes") ? wrapped_class : plain_class })
payload = { "attributes" => { "kind" => "colored", "color" => "red", "extra" => "kept" } }

from_hash = type.cast_value(payload)
from_json = type.cast_value(payload.to_json)

expect(from_hash).to be_a(wrapped_class)
expect(from_json).to be_a(wrapped_class)
expect(from_json).to have_attributes(kind: from_hash.kind, color: from_hash.color)
expect(from_json.unknown_attributes).to eq(from_hash.unknown_attributes)
end

context "when instance of illegal class is passed" do
let(:value) { 1 }

Expand Down
Loading
Loading