diff --git a/lib/couchbase-orm/base.rb b/lib/couchbase-orm/base.rb index 9a3bdb7..9950b98 100644 --- a/lib/couchbase-orm/base.rb +++ b/lib/couchbase-orm/base.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true, encoding: ASCII-8BIT +require 'set' require 'active_model' require 'active_support/hash_with_indifferent_access' require 'couchbase' @@ -53,6 +54,16 @@ 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 + + # Returns a cached Set of attribute names for efficient lookup + # This avoids repeated array-to-set conversions in assign_attributes + def self.attribute_names_set + @attribute_names_set ||= attribute_names.to_set + end + 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 +111,39 @@ 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 + + # Override assign_attributes to filter unknown attributes when raise_on_unknown_attributes is false + # This ensures consistent behavior across Document and NestedDocument + def assign_attributes(hash) + hash = hash.with_indifferent_access if hash.is_a?(Hash) + + if self.class.raise_on_unknown_attributes + super(hash.except("type")) + else + # Filter unknown attributes using cached Set for O(1) lookups + known_names = self.class.attribute_names + known_attrs = hash.slice(*known_names) + + # Use cached Set for efficient O(1) lookup of unknown keys + unknown_keys = hash.keys.reject { |k| self.class.attribute_names_set.include?(k) || k == "type" } + + if unknown_keys.any? + CouchbaseOrm.logger.warn "Ignoring unknown attribute(s) for #{self.class.name}: #{unknown_keys.join(', ')}" + end + super(known_attrs) + end + end + protected def serialized_attributes diff --git a/lib/couchbase-orm/persistence.rb b/lib/couchbase-orm/persistence.rb index ea85e87..4844489 100644 --- a/lib/couchbase-orm/persistence.rb +++ b/lib/couchbase-orm/persistence.rb @@ -153,10 +153,8 @@ def update_attribute(name, value) changed? ? save(validate: false) : true end - def assign_attributes(hash) - hash = hash.with_indifferent_access if hash.is_a?(Hash) - super(hash.except("type")) - end + # Note: assign_attributes is now handled in Document class (base.rb) + # to ensure consistent behavior across Document and NestedDocument # Updates the attributes of the model from the passed-in hash and saves the # record. If the object is invalid, the saving will fail and false will be returned. diff --git a/spec/base_spec.rb b/spec/base_spec.rb index 037da37..bcfa0fb 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -22,6 +22,33 @@ 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 NestedDocWithUnknownAttributesAllowed < CouchbaseOrm::NestedDocument + self.raise_on_unknown_attributes = false + attribute :title, :string + attribute :value, :integer +end + +class NestedDocWithDefaultBehavior < CouchbaseOrm::NestedDocument + attribute :title, :string + attribute :value, :integer +end + +class ParentDocWithNestedUnknownAllowed < CouchbaseOrm::Base + attribute :name, :string + attribute :nested_item, :nested, type: NestedDocWithUnknownAttributesAllowed +end + +class ParentDocWithNestedDefault < CouchbaseOrm::Base + attribute :name, :string + attribute :nested_item, :nested, type: NestedDocWithDefaultBehavior +end + class BaseTestWithPropertiesAlwaysExistsInDocument < CouchbaseOrm::Base self.properties_always_exists_in_document = true attribute :name, :string @@ -349,6 +376,116 @@ 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 + + context 'for NestedDocument classes' do + it 'returns false when queried on NestedDocument with raise_on_unknown_attributes = false' do + expect(NestedDocWithUnknownAttributesAllowed.raise_on_unknown_attributes).to be(false) + end + + it 'returns true by default on NestedDocument' do + expect(NestedDocWithDefaultBehavior.raise_on_unknown_attributes).to be(true) + end + + it 'silently ignores unknown attributes in NestedDocument when disabled' do + nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Test', value: 42, unknown: 'ignored') + expect(nested.title).to eq('Test') + expect(nested.value).to eq(42) + expect(nested.respond_to?(:unknown)).to be(false) + end + + it 'raises error for unknown attributes in NestedDocument when enabled' do + expect { + NestedDocWithDefaultBehavior.new(title: 'Test', value: 42, unknown: 'error') + }.to raise_error(ActiveModel::UnknownAttributeError) + end + + it 'silently ignores unknown attributes in nested documents within parent' do + parent = ParentDocWithNestedUnknownAllowed.new(name: 'Parent') + nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Nested', value: 100, extra: 'ignored') + parent.nested_item = nested + + expect(parent.nested_item.title).to eq('Nested') + expect(parent.nested_item.value).to eq(100) + expect(parent.nested_item.respond_to?(:extra)).to be(false) + end + + it 'raises error for unknown attributes in nested documents with default behavior' do + parent = ParentDocWithNestedDefault.new(name: 'Parent') + expect { + NestedDocWithDefaultBehavior.new(title: 'Nested', value: 100, extra: 'error') + }.to raise_error(ActiveModel::UnknownAttributeError) + end + + it 'works with assign_attributes on NestedDocument' do + nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Initial') + expect { + nested.assign_attributes(title: 'Updated', value: 50, unknown_field: 'ignored') + }.not_to raise_error + + expect(nested.title).to eq('Updated') + expect(nested.value).to eq(50) + expect(nested.respond_to?(:unknown_field)).to be(false) + 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)