diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index 9a3bdb7..31ba3bb 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -33,8 +33,7 @@ module CouchbaseOrm class Document include Inspectable include ::ActiveModel::Model - include ::ActiveModel::Dirty - include Changeable # override some methods from ActiveModel::Dirty (keep it included after) + include Changeable include ::ActiveModel::Attributes include ::ActiveModel::Serializers::JSON @@ -53,6 +52,10 @@ class Document class MismatchTypeError < RuntimeError; end + # Configuration option to control whether unknown attributes should raise an error + # Set to false to silently ignore unknown attributes during mass assignment + class_attribute :raise_on_unknown_attributes, default: true + def initialize(model = nil, ignore_doc_type: false, **attributes) CouchbaseOrm.logger.debug { "Initialize model #{model} with #{attributes.to_s.truncate(200)}" } @__metadata__ = Metadata.new @@ -100,6 +103,17 @@ def []=(key, value) send(:"#{key}=", value) end + # Handle assignment to unknown attributes based on raise_on_unknown_attributes configuration + # If raise_on_unknown_attributes is false, unknown attributes are silently ignored + # If raise_on_unknown_attributes is true (default), ActiveModel::UnknownAttributeError is raised + def attribute_writer_missing(name, value) + if self.class.raise_on_unknown_attributes + super + else + CouchbaseOrm.logger.warn "Ignoring unknown attribute '#{name}' for #{self.class.name}" + end + end + protected def serialized_attributes diff --git a/lib/couchbase-orm/changeable.rb b/lib/couchbase-orm/changeable.rb index ba852eb..712acf6 100644 --- a/lib/couchbase-orm/changeable.rb +++ b/lib/couchbase-orm/changeable.rb @@ -80,7 +80,6 @@ def move_changes def changes_applied move_changes - super end def reset_object! diff --git a/lib/couchbase-orm/persistence.rb b/lib/couchbase-orm/persistence.rb index ea85e87..4f55f57 100644 --- a/lib/couchbase-orm/persistence.rb +++ b/lib/couchbase-orm/persistence.rb @@ -155,7 +155,18 @@ def update_attribute(name, value) def assign_attributes(hash) hash = hash.with_indifferent_access if hash.is_a?(Hash) - super(hash.except("type")) + + # Filter unknown attributes if raise_on_unknown_attributes is false + if !self.class.raise_on_unknown_attributes + known_attrs = hash.slice(*self.class.attribute_names).except("type") + unknown_attrs = hash.keys - self.class.attribute_names - ["type"] + if unknown_attrs.any? + CouchbaseOrm.logger.warn "Ignoring unknown attribute(s) for #{self.class.name}: #{unknown_attrs.join(', ')}" + end + super(known_attrs) + else + super(hash.except("type")) + end end # Updates the attributes of the model from the passed-in hash and saves the diff --git a/lib/couchbase-orm/types/timestamp.rb b/lib/couchbase-orm/types/timestamp.rb index d3643e8..9552de7 100644 --- a/lib/couchbase-orm/types/timestamp.rb +++ b/lib/couchbase-orm/types/timestamp.rb @@ -3,10 +3,10 @@ module Types class Timestamp < ActiveModel::Type::DateTime def cast(value) return nil if value.nil? - return Time.at(value) if value.is_a?(Integer) || value.is_a?(Float) - return Time.at(value.to_i) if value.is_a?(String) && value =~ /^[0-9]+$/ - return value.utc if value.is_a?(Time) - super(value).utc + return Time.at(value).floor if value.is_a?(Integer) || value.is_a?(Float) + return Time.at(value.to_i).floor if value.is_a?(String) && value =~ /^[0-9]+$/ + return value.utc.floor if value.is_a?(Time) + super(value).utc.floor end def serialize(value) diff --git a/lib/couchbase-orm/utilities/query_helper.rb b/lib/couchbase-orm/utilities/query_helper.rb index 3f1a71c..34b64c8 100644 --- a/lib/couchbase-orm/utilities/query_helper.rb +++ b/lib/couchbase-orm/utilities/query_helper.rb @@ -117,7 +117,7 @@ def build_not_match(key, value) end def serialize_value(key, value_before_type_cast) - value = + value = if value_before_type_cast.is_a?(Array) value_before_type_cast.map do |v| attribute_types[key.to_s].serialize(attribute_types[key.to_s].cast(v)) diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 037da37..c42d2f2 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -22,6 +22,12 @@ class BaseTestWithIgnoredProperties < CouchbaseOrm::Base attribute :job, :string end +class BaseTestWithUnknownAttributesAllowed < CouchbaseOrm::Base + self.raise_on_unknown_attributes = false + attribute :name, :string + attribute :job, :string +end + class BaseTestWithPropertiesAlwaysExistsInDocument < CouchbaseOrm::Base self.properties_always_exists_in_document = true attribute :name, :string @@ -349,6 +355,65 @@ class InvalidNested < CouchbaseOrm::NestedDocument end end + describe 'handling unknown attributes' do + context 'when raise_on_unknown_attributes is set to false' do + it 'returns false when queried' do + expect(BaseTestWithUnknownAttributesAllowed.raise_on_unknown_attributes).to be(false) + end + + it 'silently ignores unknown attributes in new' do + model = BaseTestWithUnknownAttributesAllowed.new(name: 'test', job: 'dev', unknown_attr: 'value') + expect(model.name).to eq('test') + expect(model.job).to eq('dev') + expect(model.respond_to?(:unknown_attr)).to be(false) + end + + it 'silently ignores unknown attributes in assign_attributes' do + model = BaseTestWithUnknownAttributesAllowed.new(name: 'test') + expect { + model.assign_attributes(name: 'updated', job: 'engineer', foo: 'bar', baz: 'qux') + }.not_to raise_error + expect(model.name).to eq('updated') + expect(model.job).to eq('engineer') + expect(model.respond_to?(:foo)).to be(false) + expect(model.respond_to?(:baz)).to be(false) + end + + it 'only stores known attributes' do + model = BaseTestWithUnknownAttributesAllowed.new( + name: 'Alice', + job: 'Developer', + unknown_field_1: 'value1', + unknown_field_2: 'value2' + ) + # Only known attributes should be stored + expect(model.name).to eq('Alice') + expect(model.job).to eq('Developer') + expect(model.respond_to?(:unknown_field_1)).to be(false) + expect(model.respond_to?(:unknown_field_2)).to be(false) + end + end + + context 'default behavior (raise_on_unknown_attributes = true)' do + it 'returns true by default' do + expect(BaseTest.raise_on_unknown_attributes).to be(true) + end + + it 'raises ActiveModel::UnknownAttributeError on unknown attributes in new' do + expect { + BaseTest.new(name: 'bob', job: 'dev', foo: 'bar') + }.to raise_error(ActiveModel::UnknownAttributeError) + end + + it 'raises ActiveModel::UnknownAttributeError on unknown attributes in assign_attributes' do + model = BaseTest.new(name: 'bob') + expect { + model.assign_attributes(job: 'dev', foo: 'bar') + }.to raise_error(ActiveModel::UnknownAttributeError) + end + end + end + describe '.properties_always_exists_in_document' do it 'Uses NOT VALUED when properties_always_exists_in_document = false' do where_clause = BaseTest.where(name: nil) diff --git a/spec/type_nested_spec.rb b/spec/type_nested_spec.rb index 958fd56..6020e56 100644 --- a/spec/type_nested_spec.rb +++ b/spec/type_nested_spec.rb @@ -42,6 +42,12 @@ class TypeNestedTest < CouchbaseOrm::Base obj.others[1].child = SubTypeTest.new(name: "baz") obj.save! + expect(obj.others[0].name).to eq "foo" + expect(obj.others[0].tags).to eq ["foo", "bar"] + expect(obj.others[1].name).to eq "bar" + expect(obj.others[1].tags).to eq ["bar", "baz"] + expect(obj.others[1].child.name).to eq "baz" + obj = TypeNestedTest.find(obj.id) expect(obj.others[0].name).to eq "foo" expect(obj.others[0].tags).to eq ["foo", "bar"] @@ -116,7 +122,8 @@ class TypeNestedTest < CouchbaseOrm::Base obj.others[1].name = "baz" obj.flags[0] = true - obj.save! + expect { obj.save! }.to_not change { [obj.main.name, obj.others[0].name, obj.others[1].name, obj.flags] } + obj = TypeNestedTest.find(obj.id) expect(obj.main.name).to eq "bar" expect(obj.others[0].name).to eq "bar" diff --git a/spec/type_spec.rb b/spec/type_spec.rb index 6cf2290..e74cba2 100644 --- a/spec/type_spec.rb +++ b/spec/type_spec.rb @@ -4,6 +4,11 @@ require "couchbase-orm/types" class DateTimeWith3Decimal < CouchbaseOrm::Types::DateTime + def cast(value) + result = super(value) + result&.floor(3) + end + def serialize(value) value&.iso8601(3) end