Skip to content
Open
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
44 changes: 44 additions & 0 deletions lib/couchbase-orm/base.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +117 to +123

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The value parameter is not used in this method. It's a good practice in Ruby to prefix unused parameters with an underscore (_) to signal this intent to other developers and to satisfy linters.

        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
Expand Down
6 changes: 2 additions & 4 deletions lib/couchbase-orm/persistence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
137 changes: 137 additions & 0 deletions spec/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading