Skip to content
Closed
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
18 changes: 16 additions & 2 deletions lib/couchbase-orm/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +36 to 37

Choose a reason for hiding this comment

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

high

The change in module inclusion order appears to introduce a bug. ActiveModel::Attributes includes ActiveModel::Dirty. By including Changeable before ActiveModel::Attributes, the methods in ActiveModel::Dirty (like changes_applied) will override the ones in Changeable. The original code and its comment indicated that Changeable is intended to override ActiveModel::Dirty's behavior. To restore the correct override behavior, Changeable should be included after ActiveModel::Attributes.

        include ::ActiveModel::Attributes
        include Changeable

include ::ActiveModel::Serializers::JSON

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion lib/couchbase-orm/changeable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ def move_changes

def changes_applied
move_changes
super
end

def reset_object!
Expand Down
13 changes: 12 additions & 1 deletion lib/couchbase-orm/persistence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 156 to 170

Choose a reason for hiding this comment

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

medium

This assign_attributes override is located in the Persistence module, which is only included in CouchbaseOrm::Base. This means it won't apply to CouchbaseOrm::NestedDocument, leading to inconsistent behavior for handling unknown attributes between different document types. This method should be moved to the CouchbaseOrm::Document class in lib/couchbase-orm/base.rb to ensure it applies to all document types inheriting from it.


# Updates the attributes of the model from the passed-in hash and saves the
Expand Down
8 changes: 4 additions & 4 deletions lib/couchbase-orm/types/timestamp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 4 to 10

Choose a reason for hiding this comment

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

high

The addition of .floor to the return values in the cast method truncates timestamps to the nearest second, effectively removing fractional seconds. This is a significant, backward-incompatible change that is not documented in the pull request description and is unrelated to the primary goal of this PR. This could lead to data loss for applications that rely on sub-second precision. It's recommended to revert this change and submit it as a separate pull request with a clear explanation of its impact and motivation.

            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
            end


def serialize(value)
Expand Down
2 changes: 1 addition & 1 deletion lib/couchbase-orm/utilities/query_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
65 changes: 65 additions & 0 deletions spec/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion spec/type_nested_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions spec/type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading