couchbase-orm 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 253e0ee05a3522f641294d615724214c6b03ebfd189f008544e9a41f0e671916
4
- data.tar.gz: 7628e1f89db8f7649dd6795145b238a06af87cb1901e30337670c3f15b87378c
3
+ metadata.gz: 90eb0b8fb472134579071ce58442b518bd42009d3647363116f041eb65b13a85
4
+ data.tar.gz: 066cad6c6ee614382262d035d8d86e55e8d829f42733d21daddd9a91deb03786
5
5
  SHA512:
6
- metadata.gz: af199f51fb0fd808ac4920ca8eb16d50b8d6afa89a23cd311d5e39e92df87f93914368267afd373e7d8d282e50e517b892039008f3a2d234ef5bf1ea2b63bb83
7
- data.tar.gz: b0703729ad90b504026467377aecdeaed17434010f044ede50d0645ef1e51d9dadef595c31ad1f1990d7366ba7f4101c934c716f4f8cb2152a70d0f846ad7e88
6
+ metadata.gz: 2cfd5683e131c28d10499da7c42ab2000688bed5dc5382c9b7a2beab99cb8b142da16b9327966636777ca32c34bee101185c1fb8a8b1862203339b5a27adc24c
7
+ data.tar.gz: 393a5523c5a5ffb2154b8ac426e199179cebc48356fd32a7170ad6c1eed7a6984a56c4990e3e0601d88710071c22b50b8ad87c41a067b1dc5b4bdd0fec29a281
@@ -0,0 +1,28 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '[0-9]+.[0-9]+.[0-9]+'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ github-release:
13
+ runs-on: ubuntu-24.04
14
+ steps:
15
+ - uses: actions/checkout@v3
16
+ with:
17
+ fetch-depth: 0
18
+ - name: Verify tag is on master
19
+ run: |
20
+ git fetch origin master
21
+ if ! git merge-base --is-ancestor "${{ github.sha }}" origin/master; then
22
+ echo "::error::Tag ${{ github.ref_name }} (${{ github.sha }}) is not an ancestor of origin/master. Refusing to create a GitHub Release for a commit that hasn't been merged to master."
23
+ exit 1
24
+ fi
25
+ - name: Create GitHub Release
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
28
+ run: gh release create "${{ github.ref_name }}" --title "${{ github.ref_name }}" --generate-notes
data/README.md CHANGED
@@ -195,13 +195,39 @@ Check this couchbase help page to learn more on what's possible with compound ke
195
195
  Ex : Compound keys allows to decide the order of the results, and you can reverse it by passing `descending: true`
196
196
 
197
197
  ```ruby
198
- class Comment < CouchbaseOrm::Base19
198
+ class Comment < CouchbaseOrm::Base
199
199
  self.ignored_properties = [:old_name] # ignore old_name property in the model
200
200
  self.properties_always_exists_in_document = true # use is null for nil value instead of not valued for performance purpose, only possible if all properties always exists in document
201
201
  end
202
202
  ```
203
203
  You can specify `properties_always_exists_in_document` to true if all properties always exists in document, this will allow to use `is null` instead of `not valued` for nil value, this will improve performance.
204
204
 
205
+ ## Schema evolution
206
+
207
+ `ignored_properties` (above) drops a named, fixed list of legacy keys wherever a document is
208
+ decoded. For the more general case of a rolling/canary deploy - where a document written by a pod
209
+ running newer code can carry an attribute a pod still running older code hasn't declared yet -
210
+ there is `raise_on_unknown_attributes`:
211
+
212
+ ```ruby
213
+ class Comment < CouchbaseOrm::Base
214
+ self.raise_on_unknown_attributes = false # tolerate any undeclared document key instead of raising
215
+ end
216
+ ```
217
+
218
+ With the default (`true`), an undeclared key anywhere in `new`, `assign_attributes`, `find` or
219
+ `reload` raises `ActiveModel::UnknownAttributeError`, same as plain ActiveModel. Set it to `false`
220
+ and undeclared keys are dropped instead, with a warning logged the first time each one is seen on
221
+ a given class. Unlike `ignored_properties`, it is a `class_attribute`, so it is inherited by
222
+ subclasses and can be set once for a whole hierarchy (e.g. on your app's own base class) or
223
+ globally via `CouchbaseOrm::Document.raise_on_unknown_attributes = false`.
224
+
225
+ Because a dropped key was never assigned to an attribute, it is not written back out either: if
226
+ the model is saved afterwards, the key disappears from the stored document. If you need to
227
+ preserve an unknown key across a load-then-save round trip rather than only tolerate it on load,
228
+ `ignored_properties` won't help with that either - both mechanisms are read-side tolerance, not a
229
+ schemaless passthrough.
230
+
205
231
  WARNING: If a document exists without a property, the query will failed! So you must be sure that all documents have all properties.
206
232
 
207
233
 
data/RELEASE.md CHANGED
@@ -34,6 +34,10 @@
34
34
  git tag X.Y.Z
35
35
  git push origin X.Y.Z
36
36
 
37
+ Pushing the tag automatically triggers the `Release` GitHub Actions workflow, which creates the
38
+ corresponding GitHub Release (with auto-generated notes) once it confirms the tag's commit is on
39
+ `master`.
40
+
37
41
  3. Verify Clean State
38
42
 
39
43
  Ensure your local master branch is clean and up-to-date:
@@ -66,6 +70,8 @@
66
70
 
67
71
  Post-Release
68
72
 
73
+ - Verify the GitHub Release was created automatically at
74
+ https://github.com/Couchbase-Ecosystem/couchbase-ruby-orm/releases/tag/X.Y.Z
69
75
  - Announce the release if applicable (changelog, team communication, etc.)
70
76
  - Update any documentation that references the version number
71
77
 
@@ -236,4 +236,43 @@ Validations are automatically run when saving a document. If any validations fai
236
236
 
237
237
  By leveraging validations, you can ensure the quality and consistency of your data before it is persisted to Couchbase Server.
238
238
 
239
+ ## 3.7. Handling Unknown Document Properties
240
+
241
+ By default, CouchbaseOrm requires every property found on a document to have a matching declared
242
+ attribute: loading a document with an undeclared key raises `ActiveModel::UnknownAttributeError`.
243
+ This is normally what you want, but it becomes a problem during a rolling or canary deploy: a pod
244
+ running newer code can write a document with an attribute that a pod still running older code
245
+ hasn't declared yet, and that older pod will raise the moment it tries to read the document back.
246
+
247
+ Set `raise_on_unknown_attributes` to `false` on a model to tolerate this instead:
248
+
249
+ ```ruby
250
+ class User < CouchbaseOrm::Base
251
+ self.raise_on_unknown_attributes = false
252
+
253
+ attribute :name, :string
254
+ end
255
+
256
+ User.find(id) # a document with an extra, undeclared key no longer raises;
257
+ # the key is simply dropped and a warning is logged.
258
+ ```
259
+
260
+ A few things worth knowing:
261
+
262
+ - It applies everywhere a document (or a hash) is turned into attributes: `new`, `assign_attributes`,
263
+ `find`, and `reload`.
264
+ - It is a `class_attribute`, so unlike `ignored_properties` it is **inherited** by subclasses, and it
265
+ can be set once for a whole hierarchy - for example on your application's own base class - rather
266
+ than repeated per model. You can also set a global default with
267
+ `CouchbaseOrm::Document.raise_on_unknown_attributes = false` from a Rails initializer.
268
+ - A key is considered "known" if the model responds to the corresponding writer, not just if it is a
269
+ declared `attribute` - so a `belongs_to`/`has_and_belongs_to_many` association key (e.g. `parent:`)
270
+ is never treated as unknown, even though its underlying attribute is actually named `parent_id`.
271
+ - A dropped key is not written back out: since it was never assigned to an attribute, saving the
272
+ model afterwards omits it from the stored document, the same trade-off `ignored_properties` makes.
273
+ - Unlike `ignored_properties`, which removes a fixed, named list of keys wherever a document is
274
+ decoded, `raise_on_unknown_attributes` tolerates *any* undeclared key. Use `ignored_properties`
275
+ when you know exactly which legacy keys you're phasing out; use `raise_on_unknown_attributes` when
276
+ you want a model to be structurally resilient to future attributes it doesn't know about yet.
277
+
239
278
  With the model definition covered, including attributes, callbacks, and validations, you're ready to start querying and persisting data using CouchbaseOrm. In the next section, we'll explore the querying capabilities of CouchbaseOrm and how to retrieve data from Couchbase Server efficiently.
@@ -135,4 +135,23 @@ Nested documents provide a powerful way to model complex data structures and rel
135
135
 
136
136
  However, it's important to consider the trade-offs when using nested documents. Embedding too much data within a single document can lead to large document sizes and potential performance issues. It's recommended to use nested documents judiciously and to consider the access patterns and data relationships of your application.
137
137
 
138
+ ## 9.8. Unknown Properties on Nested Documents
139
+
140
+ `raise_on_unknown_attributes` (see [3.7](./03-defining-models.md#37-handling-unknown-document-properties))
141
+ applies to `CouchbaseOrm::NestedDocument` the same way it does to `CouchbaseOrm::Base`, but the
142
+ setting is per nested class - it is not inherited through composition from the parent document.
143
+ A tolerant parent embedding a strict nested class still raises if that nested document carries an
144
+ undeclared property, and vice versa:
145
+
146
+ ```ruby
147
+ class Part < CouchbaseOrm::NestedDocument
148
+ self.raise_on_unknown_attributes = false # only Part tolerates unknown properties
149
+ attribute :name, :string
150
+ end
151
+
152
+ class Car < CouchbaseOrm::Base
153
+ attribute :parts, :array, type: Part # Car's own setting (default: true) is unaffected
154
+ end
155
+ ```
156
+
138
157
  In the next section, we'll explore enums in CouchbaseOrm and how they can be used to define a fixed set of values for an attribute.
@@ -141,6 +141,17 @@ CouchbaseOrm handles the storage format for encrypted attributes but does not pe
141
141
  - All actual encryption/decryption is your application's responsibility
142
142
  - Values must be valid Base64-encoded strings
143
143
 
144
+ The `encrypted$` unwrapping in step 2 above happens for **any** key with that prefix, whether or
145
+ not it maps to a declared `:encrypted` attribute. This means that if you are retiring an encrypted
146
+ attribute with [`raise_on_unknown_attributes = false`](./03-defining-models.md#37-handling-unknown-document-properties)
147
+ instead of `ignored_properties`, a single rule covers both `legacy_field` and
148
+ `encrypted$legacy_field` in the underlying document - you don't need to list both spellings.
149
+ One case only `ignored_properties` can handle: if an `encrypted$`-prefixed key's value is not a
150
+ `{alg:, ciphertext:}`-shaped hash (e.g. it was left over from a different serialization format),
151
+ unwrapping raises before `raise_on_unknown_attributes` ever gets a chance to filter it - only
152
+ naming the exact key with `ignored_properties`, which strips it before unwrapping is attempted,
153
+ handles that case.
154
+
144
155
  ## 11.4. Considerations and Best Practices
145
156
 
146
157
  When using encrypted attributes in CouchbaseOrm, consider the following best practices:
@@ -46,3 +46,17 @@ D, [2024-05-24T11:48:00.113166 #234447] DEBUG -- : _update_record - replace user
46
46
  I, [2024-05-24T11:48:00.115239 #234447] INFO -- : User user-1-vncZNSYZj updated email to john.doe@example.com
47
47
  ```
48
48
 
49
+ ## 12.2. Unknown Attribute Warnings
50
+
51
+ When a model has [`raise_on_unknown_attributes`](./03-defining-models.md#37-handling-unknown-document-properties)
52
+ set to `false`, every `assign_attributes` call (from `new`, `find`, `reload`, ...) that drops an
53
+ undeclared key logs at `DEBUG` level. To avoid flooding your logs on high-traffic models, a `WARN`
54
+ is only emitted the first time a given (model class, property name) pair is seen in the process:
55
+
56
+ ```
57
+ D, [...] DEBUG -- : User: ignoring unknown properties ["legacy_field"]
58
+ W, [...] WARN -- : User: ignoring unknown document properties legacy_field (raise_on_unknown_attributes is false for this class - they will not be persisted if the document is saved)
59
+ ```
60
+
61
+ That warning tracking is capped so it cannot grow unbounded.
62
+
@@ -16,6 +16,8 @@ Here are some common issues you may encounter while using CouchbaseOrm:
16
16
 
17
17
  5. **Unexpected Behavior**: If you experience unexpected behavior or results, double-check your code logic, query conditions, and attribute assignments. Ensure that you are using the correct methods, parameters, and data types.
18
18
 
19
+ 6. **`ActiveModel::UnknownAttributeError` right after a deploy**: This typically happens during a rolling or canary deploy, when a document written by a pod running newer code carries an attribute a pod still running older code hasn't declared yet. See [Handling Unknown Document Properties](./03-defining-models.md#37-handling-unknown-document-properties) for `raise_on_unknown_attributes`, or `ignored_properties` if you're phasing out a specific, known set of legacy keys instead.
20
+
19
21
  ## 15.2. Debugging Tips
20
22
 
21
23
  When troubleshooting issues with CouchbaseOrm, consider the following debugging tips:
@@ -44,12 +44,6 @@ module CouchbaseOrm
44
44
  def type_for_attribute(attribute)
45
45
  attribute_types[attribute]
46
46
  end
47
-
48
- if ActiveModel::VERSION::MAJOR < 6
49
- def attribute_names
50
- attribute_types.keys
51
- end
52
- end
53
47
  end
54
48
 
55
49
  def slice(*methods)
@@ -68,25 +62,5 @@ module CouchbaseOrm
68
62
  value = send(attr_name)
69
63
  value.inspect
70
64
  end
71
-
72
- if ActiveModel::VERSION::MAJOR < 6
73
- def attribute_names
74
- self.class.attribute_names
75
- end
76
-
77
- def has_attribute?(attr_name)
78
- @attributes.key?(attr_name.to_s)
79
- end
80
-
81
- def attribute_present?(attribute)
82
- value = send(attribute)
83
- !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
84
- end
85
-
86
- def _write_attribute(attr_name, value)
87
- @attributes.write_from_user(attr_name.to_s, value)
88
- value
89
- end
90
- end
91
65
  end
92
66
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true, encoding: ASCII-8BIT
2
2
 
3
3
 
4
+ require 'set'
4
5
  require 'active_model'
5
6
  require 'active_support/hash_with_indifferent_access'
6
7
  require 'couchbase'
@@ -26,6 +27,7 @@ require 'couchbase-orm/json_transcoder'
26
27
  require 'couchbase-orm/timestamps'
27
28
  require 'couchbase-orm/active_record_compat'
28
29
  require 'couchbase-orm/strict_loading'
30
+ require 'couchbase-orm/unknown_attributes'
29
31
  require 'couchbase-orm/json_schema/validation'
30
32
  require 'couchbase-orm/utilities/properties_always_exists_in_document'
31
33
 
@@ -43,6 +45,7 @@ module CouchbaseOrm
43
45
  include ActiveRecordCompat
44
46
  include StrictLoading
45
47
  include Encrypt
48
+ include UnknownAttributes
46
49
 
47
50
  extend Enum
48
51
  extend IgnoredProperties
@@ -157,6 +157,10 @@ module CouchbaseOrm
157
157
  hash = hash.with_indifferent_access if hash.is_a?(Hash)
158
158
  super(hash.except("type"))
159
159
  end
160
+ # ActiveModel::AttributeAssignment aliases attributes= to its own
161
+ # assign_attributes at include-time, so without this, `model.attributes = hash`
162
+ # would bypass the "type" stripping above.
163
+ alias_method :attributes=, :assign_attributes
160
164
 
161
165
  # Updates the attributes of the model from the passed-in hash and saves the
162
166
  # record. If the object is invalid, the saving will fail and false will be returned.
@@ -23,14 +23,8 @@ module CouchbaseOrm
23
23
  end
24
24
  end
25
25
 
26
- if RUBY_VERSION.to_i >= 3
27
- def method_missing(name, *args, **options, &block)
28
- @proxyfied.public_send(name, *args, **options, &block)
29
- end
30
- else
31
- def method_missing(name, *args, &block)
32
- @proxyfied.public_send(name, *args, &block)
33
- end
26
+ def method_missing(name, *args, **options, &block)
27
+ @proxyfied.public_send(name, *args, **options, &block)
34
28
  end
35
29
  end
36
30
  end
@@ -5,13 +5,6 @@ require "couchbase-orm/types/array"
5
5
  require "couchbase-orm/types/nested"
6
6
  require "couchbase-orm/types/encrypted"
7
7
 
8
- if ActiveModel::VERSION::MAJOR < 6
9
- # In Rails 5, the type system cannot allow overriding the default types
10
- ActiveModel::Type.registry.instance_variable_get(:@registrations).delete_if do |k|
11
- k.matches?(:date) || k.matches?(:datetime) || k.matches?(:timestamp)
12
- end
13
- end
14
-
15
8
  ActiveModel::Type.register(:date, CouchbaseOrm::Types::Date)
16
9
  ActiveModel::Type.register(:datetime, CouchbaseOrm::Types::DateTime)
17
10
  ActiveModel::Type.register(:timestamp, CouchbaseOrm::Types::Timestamp)
@@ -0,0 +1,82 @@
1
+ require 'set'
2
+ require 'active_support/concern'
3
+
4
+ module CouchbaseOrm
5
+ # Controls what happens when a document (or a Hash/Parameters passed to
6
+ # `new`/`assign_attributes`) carries a key that has no corresponding
7
+ # attribute or writer on the model.
8
+ #
9
+ # By default (`raise_on_unknown_attributes = true`) this is unchanged
10
+ # ActiveModel behaviour: an `ActiveModel::UnknownAttributeError` is
11
+ # raised. Setting it to `false` on a model (it is inherited, like any
12
+ # `class_attribute`) makes unknown keys tolerated instead: they are
13
+ # dropped before assignment and a warning is logged.
14
+ #
15
+ # This exists to survive rolling/canary deploys: a document written by a
16
+ # pod running newer code can carry attributes a pod still running older
17
+ # code does not know about yet. Without this, reading that document on
18
+ # the old code raises and the request fails.
19
+ #
20
+ # A key is considered "known" using the same test ActiveModel itself uses
21
+ # to decide whether to raise (`respond_to?(:"#{key}=")`), not attribute
22
+ # membership - this matters because association writers (`belongs_to`,
23
+ # `has_and_belongs_to_many`) define a setter (e.g. `parent=`) for a name
24
+ # that is not itself a declared attribute (the attribute is `parent_id`).
25
+ # Filtering by attribute name would silently drop those.
26
+ #
27
+ # Unknown keys are only ever dropped from the in-memory assignment. If
28
+ # the model is saved afterwards, `serialized_attributes` only emits
29
+ # declared attributes, so the unknown key disappears from the stored
30
+ # document too - the same trade-off `ignored_properties` already makes.
31
+ module UnknownAttributes
32
+ extend ActiveSupport::Concern
33
+
34
+ # Caps the process-wide memory used to track which (class, key)
35
+ # warnings have already been emitted. Unknown keys come from
36
+ # document content, i.e. are not under the application's control,
37
+ # so this must be bounded. Once the cap is hit, unknown attributes
38
+ # are still filtered (and still logged at debug level) - only the
39
+ # warn-once escalation stops.
40
+ MAX_WARNED = 1_000
41
+
42
+ @warned = Set.new
43
+
44
+ included do
45
+ class_attribute :raise_on_unknown_attributes,
46
+ instance_accessor: false, instance_predicate: false, default: true
47
+ end
48
+
49
+ # Filters out unknown keys before assignment when
50
+ # `raise_on_unknown_attributes` is false; otherwise unchanged.
51
+ def assign_attributes(attributes)
52
+ return super if self.class.raise_on_unknown_attributes
53
+ # Garbage input (nil, an Integer, a bare Object...) must still get
54
+ # ActiveModel's own "you must pass a hash" ArgumentError from
55
+ # `super`, not a NoMethodError from calling each_pair on it below.
56
+ return super unless attributes.respond_to?(:each_pair)
57
+
58
+ unknown = attributes.each_pair.filter_map { |key, _| key unless respond_to?(:"#{key}=") }
59
+ return super if unknown.empty?
60
+
61
+ UnknownAttributes.report(self.class, unknown)
62
+ super(attributes.except(*unknown))
63
+ end
64
+ alias_method :attributes=, :assign_attributes
65
+
66
+ class << self
67
+ # @api private
68
+ def report(klass, keys)
69
+ CouchbaseOrm.logger.debug { "#{klass.name}: ignoring unknown properties #{keys.inspect}" }
70
+
71
+ newly_warned = @warned.size >= MAX_WARNED ? [] : keys.select { |key| @warned.add?("#{klass.name}##{key}") }
72
+ return if newly_warned.empty?
73
+
74
+ CouchbaseOrm.logger.warn(
75
+ "#{klass.name}: ignoring unknown document properties #{newly_warned.join(', ')} " \
76
+ "(raise_on_unknown_attributes is false for this class - " \
77
+ "they will not be persisted if the document is saved)"
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true, encoding: ASCII-8BIT
2
2
 
3
3
  module CouchbaseOrm
4
- VERSION = '3.2.0'
4
+ VERSION = '3.3.0'
5
5
  end
data/spec/base_spec.rb CHANGED
@@ -22,6 +22,33 @@ class BaseTestWithIgnoredProperties < CouchbaseOrm::Base
22
22
  attribute :job, :string
23
23
  end
24
24
 
25
+ class BaseTestWithUnknownAttributesAllowed < CouchbaseOrm::Base
26
+ self.raise_on_unknown_attributes = false
27
+ attribute :name, :string
28
+ attribute :job, :string
29
+ end
30
+
31
+ class NestedDocWithUnknownAttributesAllowed < CouchbaseOrm::NestedDocument
32
+ self.raise_on_unknown_attributes = false
33
+ attribute :title, :string
34
+ attribute :value, :integer
35
+ end
36
+
37
+ class NestedDocWithDefaultBehavior < CouchbaseOrm::NestedDocument
38
+ attribute :title, :string
39
+ attribute :value, :integer
40
+ end
41
+
42
+ class ParentDocWithNestedUnknownAllowed < CouchbaseOrm::Base
43
+ attribute :name, :string
44
+ attribute :nested_item, :nested, type: NestedDocWithUnknownAttributesAllowed
45
+ end
46
+
47
+ class ParentDocWithNestedDefault < CouchbaseOrm::Base
48
+ attribute :name, :string
49
+ attribute :nested_item, :nested, type: NestedDocWithDefaultBehavior
50
+ end
51
+
25
52
  class BaseTestWithPropertiesAlwaysExistsInDocument < CouchbaseOrm::Base
26
53
  self.properties_always_exists_in_document = true
27
54
  attribute :name, :string
@@ -349,6 +376,116 @@ describe CouchbaseOrm::Base do
349
376
  end
350
377
  end
351
378
 
379
+ describe 'handling unknown attributes' do
380
+ context 'when raise_on_unknown_attributes is set to false' do
381
+ it 'returns false when queried' do
382
+ expect(BaseTestWithUnknownAttributesAllowed.raise_on_unknown_attributes).to be(false)
383
+ end
384
+
385
+ it 'silently ignores unknown attributes in new' do
386
+ model = BaseTestWithUnknownAttributesAllowed.new(name: 'test', job: 'dev', unknown_attr: 'value')
387
+ expect(model.name).to eq('test')
388
+ expect(model.job).to eq('dev')
389
+ expect(model.respond_to?(:unknown_attr)).to be(false)
390
+ end
391
+
392
+ it 'silently ignores unknown attributes in assign_attributes' do
393
+ model = BaseTestWithUnknownAttributesAllowed.new(name: 'test')
394
+ expect {
395
+ model.assign_attributes(name: 'updated', job: 'engineer', foo: 'bar', baz: 'qux')
396
+ }.not_to raise_error
397
+ expect(model.name).to eq('updated')
398
+ expect(model.job).to eq('engineer')
399
+ expect(model.respond_to?(:foo)).to be(false)
400
+ expect(model.respond_to?(:baz)).to be(false)
401
+ end
402
+
403
+ it 'only stores known attributes' do
404
+ model = BaseTestWithUnknownAttributesAllowed.new(
405
+ name: 'Alice',
406
+ job: 'Developer',
407
+ unknown_field_1: 'value1',
408
+ unknown_field_2: 'value2'
409
+ )
410
+ # Only known attributes should be stored
411
+ expect(model.name).to eq('Alice')
412
+ expect(model.job).to eq('Developer')
413
+ expect(model.respond_to?(:unknown_field_1)).to be(false)
414
+ expect(model.respond_to?(:unknown_field_2)).to be(false)
415
+ end
416
+ end
417
+
418
+ context 'default behavior (raise_on_unknown_attributes = true)' do
419
+ it 'returns true by default' do
420
+ expect(BaseTest.raise_on_unknown_attributes).to be(true)
421
+ end
422
+
423
+ it 'raises ActiveModel::UnknownAttributeError on unknown attributes in new' do
424
+ expect {
425
+ BaseTest.new(name: 'bob', job: 'dev', foo: 'bar')
426
+ }.to raise_error(ActiveModel::UnknownAttributeError)
427
+ end
428
+
429
+ it 'raises ActiveModel::UnknownAttributeError on unknown attributes in assign_attributes' do
430
+ model = BaseTest.new(name: 'bob')
431
+ expect {
432
+ model.assign_attributes(job: 'dev', foo: 'bar')
433
+ }.to raise_error(ActiveModel::UnknownAttributeError)
434
+ end
435
+ end
436
+
437
+ context 'for NestedDocument classes' do
438
+ it 'returns false when queried on NestedDocument with raise_on_unknown_attributes = false' do
439
+ expect(NestedDocWithUnknownAttributesAllowed.raise_on_unknown_attributes).to be(false)
440
+ end
441
+
442
+ it 'returns true by default on NestedDocument' do
443
+ expect(NestedDocWithDefaultBehavior.raise_on_unknown_attributes).to be(true)
444
+ end
445
+
446
+ it 'silently ignores unknown attributes in NestedDocument when disabled' do
447
+ nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Test', value: 42, unknown: 'ignored')
448
+ expect(nested.title).to eq('Test')
449
+ expect(nested.value).to eq(42)
450
+ expect(nested.respond_to?(:unknown)).to be(false)
451
+ end
452
+
453
+ it 'raises error for unknown attributes in NestedDocument when enabled' do
454
+ expect {
455
+ NestedDocWithDefaultBehavior.new(title: 'Test', value: 42, unknown: 'error')
456
+ }.to raise_error(ActiveModel::UnknownAttributeError)
457
+ end
458
+
459
+ it 'silently ignores unknown attributes in nested documents within parent' do
460
+ parent = ParentDocWithNestedUnknownAllowed.new(name: 'Parent')
461
+ nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Nested', value: 100, extra: 'ignored')
462
+ parent.nested_item = nested
463
+
464
+ expect(parent.nested_item.title).to eq('Nested')
465
+ expect(parent.nested_item.value).to eq(100)
466
+ expect(parent.nested_item.respond_to?(:extra)).to be(false)
467
+ end
468
+
469
+ it 'raises error for unknown attributes in nested documents with default behavior' do
470
+ parent = ParentDocWithNestedDefault.new(name: 'Parent')
471
+ expect {
472
+ NestedDocWithDefaultBehavior.new(title: 'Nested', value: 100, extra: 'error')
473
+ }.to raise_error(ActiveModel::UnknownAttributeError)
474
+ end
475
+
476
+ it 'works with assign_attributes on NestedDocument' do
477
+ nested = NestedDocWithUnknownAttributesAllowed.new(title: 'Initial')
478
+ expect {
479
+ nested.assign_attributes(title: 'Updated', value: 50, unknown_field: 'ignored')
480
+ }.not_to raise_error
481
+
482
+ expect(nested.title).to eq('Updated')
483
+ expect(nested.value).to eq(50)
484
+ expect(nested.respond_to?(:unknown_field)).to be(false)
485
+ end
486
+ end
487
+ end
488
+
352
489
  describe '.properties_always_exists_in_document' do
353
490
  it 'Uses NOT VALUED when properties_always_exists_in_document = false' do
354
491
  where_clause = BaseTest.where(name: nil)
@@ -3,6 +3,18 @@ require './lib/couchbase-orm/active_record_compat'
3
3
  class Foo
4
4
  include CouchbaseOrm::ActiveRecordCompat
5
5
 
6
+ def self.attribute_names
7
+ %w[id compute_age]
8
+ end
9
+
10
+ def self.attribute_types
11
+ { 'id' => :string }
12
+ end
13
+
14
+ def attribute_names
15
+ self.class.attribute_names
16
+ end
17
+
6
18
  def compute_age
7
19
  10 + 32
8
20
  end
@@ -21,4 +33,65 @@ describe CouchbaseOrm::ActiveRecordCompat do
21
33
  expect(foo.values_at([:compute_age])).to eq([42])
22
34
  end
23
35
  end
36
+
37
+ describe '.primary_key' do
38
+ it 'is always "id"' do
39
+ expect(Foo.primary_key).to eq('id')
40
+ end
41
+ end
42
+
43
+ describe '.base_class?' do
44
+ it 'is always true' do
45
+ expect(Foo.base_class?).to be true
46
+ end
47
+ end
48
+
49
+ describe '.column_names' do
50
+ it 'delegates to .attribute_names' do
51
+ expect(Foo.column_names).to eq(%w[id compute_age])
52
+ end
53
+ end
54
+
55
+ describe '.abstract_class?' do
56
+ it 'is always false' do
57
+ expect(Foo.abstract_class?).to be false
58
+ end
59
+ end
60
+
61
+ describe '.connected?' do
62
+ it 'is always true' do
63
+ expect(Foo.connected?).to be true
64
+ end
65
+ end
66
+
67
+ describe '.table_exists?' do
68
+ it 'is always true' do
69
+ expect(Foo.table_exists?).to be true
70
+ end
71
+ end
72
+
73
+ describe '._reflect_on_association' do
74
+ it 'is always false' do
75
+ expect(Foo._reflect_on_association(:anything)).to be false
76
+ end
77
+ end
78
+
79
+ describe '.type_for_attribute' do
80
+ it 'delegates to .attribute_types' do
81
+ expect(Foo.type_for_attribute('id')).to eq(:string)
82
+ end
83
+ end
84
+
85
+ describe '#_has_attribute?' do
86
+ it 'checks membership in #attribute_names' do
87
+ expect(foo._has_attribute?(:compute_age)).to be true
88
+ expect(foo._has_attribute?(:unknown)).to be false
89
+ end
90
+ end
91
+
92
+ describe '#attribute_for_inspect' do
93
+ it 'inspects the value returned by the named method' do
94
+ expect(foo.attribute_for_inspect(:compute_age)).to eq('42')
95
+ end
96
+ end
24
97
  end
@@ -13,4 +13,41 @@ describe 'CouchbaseOrm::Changeable' do
13
13
  doc = Doc.find(doc.id)
14
14
  expect(doc.changes).to be_empty
15
15
  end
16
+
17
+ describe 'per-attribute dirty methods generated by create_dirty_methods' do
18
+ # `changes_applied` runs the same in-memory bookkeeping create/update
19
+ # triggers after a save (no DB round trip needed to exercise it).
20
+ let(:doc) do
21
+ Doc.new(title: 'initial').tap do |d|
22
+ d.title = 'Test'
23
+ d.changes_applied
24
+ end
25
+ end
26
+
27
+ it 'exposes the change made in the last save' do
28
+ expect(doc.changed?).to be false # cleared by changes_applied
29
+ expect(doc.title_change).to be_nil # no change pending right now
30
+ expect(doc.will_save_change_to_title?).to be false
31
+ expect(doc.title_previously_was).to eq('initial')
32
+ expect(doc.title_before_last_save).to be_nil # only set by a real save, not by changes_applied alone
33
+ expect(doc.title_previously_changed?).to be true
34
+ expect(doc.title_previous_change).to eq(['initial', 'Test'])
35
+ end
36
+
37
+ it 'exposes the change during the save that triggered it, with an explicit from/to' do
38
+ expect(doc.saved_change_to_title?(from: 'initial')).to be true
39
+ expect(doc.saved_change_to_title?(to: 'Test')).to be true
40
+ expect(doc.saved_change_to_title?(from: 'initial', to: 'Test')).to be true
41
+ expect(doc.saved_change_to_title?(from: 'nope')).to be false
42
+ end
43
+
44
+ it 'can flag an attribute as about to change, and reset it back' do
45
+ expect { doc.title_will_change! }.not_to raise_error
46
+ expect { doc.reset_title! }.not_to raise_error
47
+ end
48
+
49
+ it 'falls back to nil for an attribute that was never saved' do
50
+ expect(Doc.new.title_previously_was).to be_nil
51
+ end
52
+ end
16
53
  end
@@ -0,0 +1,53 @@
1
+ require 'couchbase-orm/proxies/bucket_proxy'
2
+ require 'couchbase-orm/proxies/n1ql_proxy'
3
+ require 'couchbase-orm/proxies/results_proxy'
4
+
5
+ RSpec.describe CouchbaseOrm::BucketProxy do
6
+ let(:proxyfied) { double('bucket') }
7
+
8
+ it 'raises when asked to proxy nil' do
9
+ expect { CouchbaseOrm::BucketProxy.new(nil) }.to raise_error(ArgumentError)
10
+ end
11
+
12
+ it 'delegates unknown methods to the proxyfied object' do
13
+ allow(proxyfied).to receive(:name).and_return('my_bucket')
14
+ expect(CouchbaseOrm::BucketProxy.new(proxyfied).name).to eq('my_bucket')
15
+ end
16
+
17
+ it 'delegates unknown methods with args, kwargs and a block' do
18
+ allow(proxyfied).to receive(:default_collection).with('a', b: 1).and_yield(:yielded)
19
+ CouchbaseOrm::BucketProxy.new(proxyfied).default_collection('a', b: 1) do |arg|
20
+ expect(arg).to eq(:yielded)
21
+ end
22
+ end
23
+
24
+ it 'wraps #n1ql in a N1qlProxy' do
25
+ allow(proxyfied).to receive(:n1ql).and_return(:the_n1ql)
26
+ expect(CouchbaseOrm::BucketProxy.new(proxyfied).n1ql).to be_a(CouchbaseOrm::N1qlProxy)
27
+ end
28
+
29
+ describe '#view' do
30
+ it 'wraps the result in a ResultsProxy and caches it for the same design/view' do
31
+ allow(proxyfied).to receive(:view).with('design', 'view').and_return([:row1, :row2])
32
+ proxy = CouchbaseOrm::BucketProxy.new(proxyfied)
33
+
34
+ first = proxy.view('design', 'view')
35
+ second = proxy.view('design', 'view')
36
+
37
+ expect(first).to be_a(CouchbaseOrm::ResultsProxy)
38
+ expect(second).to be(first)
39
+ expect(proxyfied).to have_received(:view).once
40
+ end
41
+
42
+ it 'requeries when the design/view pair changes' do
43
+ allow(proxyfied).to receive(:view).with('design', 'view').and_return([:row1])
44
+ allow(proxyfied).to receive(:view).with('design', 'other').and_return([:row2])
45
+ proxy = CouchbaseOrm::BucketProxy.new(proxyfied)
46
+
47
+ proxy.view('design', 'view')
48
+ proxy.view('design', 'other')
49
+
50
+ expect(proxyfied).to have_received(:view).twice
51
+ end
52
+ end
53
+ end
@@ -25,6 +25,16 @@ describe CouchbaseOrm::Types::Encrypted do
25
25
  let(:the_secret) { "\x17`\x1F\xEE\el\xE0\x9F<\x94\xFE\x8A.\x1A\x92\xB9\xC3@\x86\x9Cp\xBEl\x86\x0E\x8CJ\tB\x97*U)\x96\x06\xA3\xE9\x84\xA6xW%\xDCT\x8C^\xEA\t\xC7\xD8\xFC\xF1\xD3\xD3\xE2\xEA\x89\xCBuUs\xB3\xFF'W>\xDE\x9CP\xA9\xDE%\xA2\xDE\x11\xFD\b\x9C\xD4\x87J,\x91\x02f\x16R\xDE\x908\x05\x1C\xF9\xDF{\x0F\xB3e\xB2\xB2\x96\xD7\xCC\x16As\xD3I\x02w\xE0\x8FL\xC6S\xEFP\xAC\x15\e^\xC4!\x15\"KF1\x17\x06\xA0N\x00\x18\xBA\x87\xEA?H\xD4<\xB5\xBCV\xB50\fc\xC9F\"\xF0B\eg%\x8E\x88\xD0\x9Bc\xE4\x93\t\x98\xC8\x87\xCB4]\xD9K\xA3\xDF\x13Q\xC0T\xCA\x91;\b\x9Cp\xE0\x7FR h\xDA\xB7\xD5\x869\f\xCA\x80\x802\x19\x19\xDD\x9DO\xAE}\xCA\eX\xA3\xA8\xBE\xE1\xBCW0g\x19@5n\r\xD8\xF3\x05\x7F4\x9CI\x1F3\xC0\xBDQJyG\v\xED!s\xD5\xD0&\xC1\x1A\xBC\x17\xFD\x9Cd\xB5\xAF\xB6U\x8A" }
26
26
  let(:base64_secret) { Base64.strict_encode64(the_secret) }
27
27
 
28
+ describe "#serialize" do
29
+ it "returns nil for a nil value" do
30
+ expect(CouchbaseOrm::Types::Encrypted.new.serialize(nil)).to be_nil
31
+ end
32
+
33
+ it "returns the value unchanged otherwise (wrapping happens in Encrypt#encode_encrypted_attributes)" do
34
+ expect(CouchbaseOrm::Types::Encrypted.new.serialize(base64_secret)).to eq(base64_secret)
35
+ end
36
+ end
37
+
28
38
  it "prefix attribute on serialization" do
29
39
  obj = TypeEncryptedTest.new(secret: base64_secret, secret2: "a secret")
30
40
  expect_serialized_secret(obj)
@@ -50,6 +60,16 @@ describe CouchbaseOrm::Types::Encrypted do
50
60
  expect(obj.as_json["secret2"]).to eq "a secret"
51
61
  end
52
62
 
63
+ it "raises when serializing a non-String value for an encrypted attribute" do
64
+ obj = TypeEncryptedTest.new(secret: 12345)
65
+ expect { obj.send(:serialized_attributes) }.to raise_error(/Can not serialize value/)
66
+ end
67
+
68
+ it "raises from #as_json when a non-String value is held by an encrypted attribute" do
69
+ obj = TypeEncryptedTest.new(secret: 12345)
70
+ expect { obj.as_json }.to raise_error(/Can not serialize value/)
71
+ end
72
+
53
73
  it "prefix with custom algo" do
54
74
  obj = SpecificAlgoTest.new(secret: base64_secret)
55
75
  expect(obj.send(:serialized_attributes)["encrypted$secret"]).to eq({alg:"3DES", ciphertext: base64_secret})
@@ -18,6 +18,18 @@ class TypeNestedTest < CouchbaseOrm::Base
18
18
  end
19
19
 
20
20
  describe CouchbaseOrm::Types::Nested do
21
+ describe "#serialize" do
22
+ it "builds an instance from a raw Hash before serializing it, filtering ignored properties" do
23
+ # `serialize` is normally called with an already-built NestedDocument
24
+ # instance (see the specs below); this exercises its Hash branch
25
+ # directly, using string keys to match the shape a real document
26
+ # (and thus `ignored_properties`, which stores string names) has.
27
+ type = CouchbaseOrm::Types::Nested.new(type: SubTypeWithIgnoredProperties)
28
+ serialized = type.serialize("name" => "Nested", "value" => "Valid", "deprecated_property" => "should be dropped")
29
+ expect(serialized).to eq("name" => "Nested", "value" => "Valid")
30
+ end
31
+ end
32
+
21
33
  it "should be able to store and retrieve a nested object" do
22
34
  obj = TypeNestedTest.new
23
35
  obj.main = SubTypeTest.new
@@ -255,4 +267,84 @@ describe CouchbaseOrm::Types::Nested do
255
267
  expect(parent.nested.value).to eq("Parent Value")
256
268
  end
257
269
  end
270
+
271
+ describe "raise_on_unknown_attributes" do
272
+ class SubTypeWithUnknownAttributesAllowed < CouchbaseOrm::NestedDocument
273
+ self.raise_on_unknown_attributes = false
274
+ attribute :label, :string
275
+ end
276
+
277
+ class SubTypeWithDefaultUnknownAttributes < CouchbaseOrm::NestedDocument
278
+ attribute :label, :string
279
+ end
280
+
281
+ class ParentWithNestedUnknownAttributesAllowed < CouchbaseOrm::Base
282
+ self.raise_on_unknown_attributes = false
283
+ attribute :title, :string
284
+ attribute :main, :nested, type: SubTypeWithUnknownAttributesAllowed
285
+ attribute :others, :array, type: SubTypeWithUnknownAttributesAllowed
286
+ end
287
+
288
+ class ParentWithStrictNestedUnknownAttributes < CouchbaseOrm::Base
289
+ self.raise_on_unknown_attributes = false
290
+ attribute :title, :string
291
+ attribute :main, :nested, type: SubTypeWithDefaultUnknownAttributes
292
+ end
293
+
294
+ it "tolerates an unknown key in a nested document assigned as a Hash, via Types::Nested#cast" do
295
+ # This is the path the actual document-loading code takes; assigning an
296
+ # already-built NestedDocument *instance* (as opposed to a Hash) never
297
+ # reaches Types::Nested#cast at all.
298
+ parent = ParentWithNestedUnknownAttributesAllowed.new(title: "Parent")
299
+ parent.main = SubTypeWithUnknownAttributesAllowed.new(label: "Main")
300
+ parent.save!
301
+
302
+ doc_id = parent.id
303
+ raw_doc = ParentWithNestedUnknownAttributesAllowed.bucket.default_collection.get(doc_id).content
304
+ raw_doc["main"]["legacy"] = "should be ignored"
305
+ ParentWithNestedUnknownAttributesAllowed.bucket.default_collection.replace(doc_id, raw_doc)
306
+
307
+ parent.reload
308
+ expect(parent.main.label).to eq("Main")
309
+ expect(parent.main.attributes.keys).not_to include("legacy")
310
+
311
+ # Same assertion via `find` - a different code path (the GetResult
312
+ # branch of Document#initialize) than `reload`.
313
+ reloaded = ParentWithNestedUnknownAttributesAllowed.find(doc_id)
314
+ expect(reloaded.main.label).to eq("Main")
315
+ expect(reloaded.main.attributes.keys).not_to include("legacy")
316
+ end
317
+
318
+ it "tolerates an unknown key in a nested document inside a Types::Array, assigned as Hashes" do
319
+ parent = ParentWithNestedUnknownAttributesAllowed.new(title: "Parent")
320
+ parent.others = [
321
+ SubTypeWithUnknownAttributesAllowed.new(label: "First"),
322
+ SubTypeWithUnknownAttributesAllowed.new(label: "Second")
323
+ ]
324
+ parent.save!
325
+
326
+ doc_id = parent.id
327
+ raw_doc = ParentWithNestedUnknownAttributesAllowed.bucket.default_collection.get(doc_id).content
328
+ raw_doc["others"][0]["legacy"] = "should be ignored"
329
+ ParentWithNestedUnknownAttributesAllowed.bucket.default_collection.replace(doc_id, raw_doc)
330
+
331
+ parent.reload
332
+ expect(parent.others.map(&:label)).to eq(["First", "Second"])
333
+ expect(parent.others[0].attributes.keys).not_to include("legacy")
334
+ expect(parent.others[1].attributes.keys).not_to include("legacy")
335
+ end
336
+
337
+ it "is per nested class: a tolerant parent does not make a strict nested document tolerant" do
338
+ parent = ParentWithStrictNestedUnknownAttributes.new(title: "Parent")
339
+ parent.main = SubTypeWithDefaultUnknownAttributes.new(label: "Main")
340
+ parent.save!
341
+
342
+ doc_id = parent.id
343
+ raw_doc = ParentWithStrictNestedUnknownAttributes.bucket.default_collection.get(doc_id).content
344
+ raw_doc["main"]["legacy"] = "should raise"
345
+ ParentWithStrictNestedUnknownAttributes.bucket.default_collection.replace(doc_id, raw_doc)
346
+
347
+ expect { parent.reload }.to raise_error(ActiveModel::UnknownAttributeError)
348
+ end
349
+ end
258
350
  end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ require File.expand_path("../support", __FILE__)
4
+
5
+ # A minimal stand-in for ActionController::Parameters (each_pair/each/except,
6
+ # no #to_hash). Real Parameters would work too, but pulling in actionpack
7
+ # here would make this spec's outcome depend on whatever version bundler
8
+ # happens to resolve it to; what actually matters is that the filter is
9
+ # guarded by `respond_to?(:each_pair)`, not `is_a?(Hash)`.
10
+ #
11
+ # `each` is required too, not just `each_pair`: ActiveModel::AttributeAssignment's
12
+ # own `_assign_attributes` (which our `super` eventually reaches) iterates via
13
+ # `.each` on ActiveModel < 8.0, and via `.each_pair` on 8.0+.
14
+ class EachPairOnly
15
+ def initialize(hash)
16
+ @hash = hash
17
+ end
18
+
19
+ def each_pair(&block)
20
+ @hash.each_pair(&block)
21
+ end
22
+ alias_method :each, :each_pair
23
+
24
+ def except(*keys)
25
+ EachPairOnly.new(@hash.except(*keys))
26
+ end
27
+
28
+ def empty?
29
+ @hash.empty?
30
+ end
31
+ end
32
+
33
+ class UnknownAttrsStrict < CouchbaseOrm::Base
34
+ attribute :name, :string
35
+ end
36
+
37
+ class UnknownAttrsTolerant < CouchbaseOrm::Base
38
+ self.raise_on_unknown_attributes = false
39
+ attribute :name, :string
40
+ attribute :secret, :encrypted
41
+ end
42
+
43
+ class UnknownAttrsTolerantChild < UnknownAttrsTolerant
44
+ end
45
+
46
+ class UnknownAttrsBoth < CouchbaseOrm::Base
47
+ self.raise_on_unknown_attributes = false
48
+ self.ignored_properties = [:legacy_ignored]
49
+ attribute :name, :string
50
+ end
51
+
52
+ class UnknownAttrsAssocParent < CouchbaseOrm::Base
53
+ attribute :name, :string
54
+ end
55
+
56
+ class UnknownAttrsAssocChild < CouchbaseOrm::Base
57
+ self.raise_on_unknown_attributes = false
58
+ attribute :name, :string
59
+ belongs_to :unknown_attrs_assoc_parent, dependent: :destroy
60
+ end
61
+
62
+ RSpec.describe "raise_on_unknown_attributes" do
63
+ # No public reset API on purpose (it would be test-only surface on
64
+ # production code) - reach into the module's own warn-once tracking
65
+ # directly so these examples don't depend on run order.
66
+ before { CouchbaseOrm::UnknownAttributes.instance_variable_get(:@warned).clear }
67
+
68
+ describe "defaults" do
69
+ it "defaults to true" do
70
+ expect(CouchbaseOrm::Base.raise_on_unknown_attributes).to be true
71
+ expect(UnknownAttrsStrict.raise_on_unknown_attributes).to be true
72
+ end
73
+
74
+ it "still raises ActiveModel::UnknownAttributeError when true" do
75
+ expect { UnknownAttrsStrict.new(name: 'bob', nope: 'x') }.to raise_error(ActiveModel::UnknownAttributeError)
76
+ model = UnknownAttrsStrict.new(name: 'bob')
77
+ expect { model.assign_attributes(nope: 'x') }.to raise_error(ActiveModel::UnknownAttributeError)
78
+ end
79
+ end
80
+
81
+ describe "inheritance" do
82
+ it "is inherited by subclasses, unlike ignored_properties" do
83
+ expect(UnknownAttrsTolerantChild.raise_on_unknown_attributes).to be false
84
+ end
85
+
86
+ it "does not leak from a subclass back to its parent or to Base" do
87
+ expect(UnknownAttrsTolerant.raise_on_unknown_attributes).to be false
88
+ expect(CouchbaseOrm::Base.raise_on_unknown_attributes).to be true
89
+ end
90
+ end
91
+
92
+ describe "association writers are not treated as unknown attributes" do
93
+ it "still resolves belongs_to via its writer, not via attribute_names" do
94
+ parent = UnknownAttrsAssocParent.create!(name: 'parent')
95
+ child = UnknownAttrsAssocChild.new(name: 'child', unknown_attrs_assoc_parent: parent, really_unknown: 'x')
96
+
97
+ expect(child.unknown_attrs_assoc_parent_id).to eq(parent.id)
98
+ expect(child.respond_to?(:really_unknown)).to be false
99
+ end
100
+ end
101
+
102
+ describe "given a document with an unknown property" do
103
+ let(:doc_id) { "unknown_attrs_tolerant_#{SecureRandom.hex}" }
104
+ let(:document_properties) do
105
+ {
106
+ 'type' => UnknownAttrsTolerant.design_document,
107
+ 'name' => 'Alice',
108
+ 'server_encrypted_content' => 'not yet known to this code'
109
+ }
110
+ end
111
+
112
+ before { UnknownAttrsTolerant.bucket.default_collection.upsert doc_id, document_properties }
113
+ after { UnknownAttrsTolerant.bucket.default_collection.remove doc_id }
114
+
115
+ it "does not raise on find (the INC-5405 path) and drops the unknown key" do
116
+ model = UnknownAttrsTolerant.find(doc_id)
117
+ expect(model.name).to eq('Alice')
118
+ expect(model.attributes.keys).not_to include('server_encrypted_content')
119
+ end
120
+
121
+ it "raises on find when raise_on_unknown_attributes is true" do
122
+ document_properties['type'] = UnknownAttrsStrict.design_document
123
+ UnknownAttrsStrict.bucket.default_collection.upsert doc_id, document_properties
124
+ expect { UnknownAttrsStrict.find(doc_id) }.to raise_error(ActiveModel::UnknownAttributeError)
125
+ end
126
+
127
+ it "does not raise on reload" do
128
+ model = UnknownAttrsTolerant.find(doc_id)
129
+ expect { model.reload }.not_to raise_error
130
+ expect(model.attributes.keys).not_to include('server_encrypted_content')
131
+ end
132
+
133
+ it "logs a warning exactly once per (class, key), and debug on every call" do
134
+ allow(CouchbaseOrm.logger).to receive(:debug).and_call_original
135
+ expect(CouchbaseOrm.logger).to receive(:warn).once.with(a_string_including('server_encrypted_content'))
136
+
137
+ UnknownAttrsTolerant.find(doc_id)
138
+ UnknownAttrsTolerant.find(doc_id)
139
+ end
140
+
141
+ it "emits no warning at all when raise_on_unknown_attributes is true" do
142
+ document_properties['type'] = UnknownAttrsStrict.design_document
143
+ UnknownAttrsStrict.bucket.default_collection.upsert doc_id, document_properties
144
+ expect(CouchbaseOrm.logger).not_to receive(:warn)
145
+ expect { UnknownAttrsStrict.find(doc_id) }.to raise_error(ActiveModel::UnknownAttributeError)
146
+ end
147
+
148
+ it "drops the unknown key from the stored document on the next save (same trade-off as ignored_properties)" do
149
+ model = UnknownAttrsTolerant.find(doc_id)
150
+ model.name = 'Alice Updated'
151
+ expect { model.save! }.to change {
152
+ UnknownAttrsTolerant.bucket.default_collection.get(doc_id).content.keys.sort
153
+ }.from(%w[name server_encrypted_content type]).to(%w[name type])
154
+ end
155
+ end
156
+
157
+ describe "interaction with encrypted$-prefixed keys" do
158
+ it "tolerates an unknown key that only happens to carry the encrypted$ prefix" do
159
+ doc_id = "unknown_attrs_tolerant_enc_#{SecureRandom.hex}"
160
+ UnknownAttrsTolerant.bucket.default_collection.upsert doc_id, {
161
+ 'type' => UnknownAttrsTolerant.design_document,
162
+ 'name' => 'Bob',
163
+ 'encrypted$tanker_encrypted_content' => { 'alg' => 'tanker', 'ciphertext' => 'legacy' }
164
+ }
165
+
166
+ model = UnknownAttrsTolerant.find(doc_id)
167
+ expect(model.name).to eq('Bob')
168
+ expect(model.attributes.keys).not_to include('tanker_encrypted_content', 'encrypted$tanker_encrypted_content')
169
+ ensure
170
+ UnknownAttrsTolerant.bucket.default_collection.remove doc_id
171
+ end
172
+ end
173
+
174
+ describe "combined with ignored_properties" do
175
+ let(:doc_id) { "unknown_attrs_both_#{SecureRandom.hex}" }
176
+
177
+ before do
178
+ UnknownAttrsBoth.bucket.default_collection.upsert doc_id, {
179
+ 'type' => UnknownAttrsBoth.design_document,
180
+ 'name' => 'Carol',
181
+ 'legacy_ignored' => 'handled by ignored_properties',
182
+ 'other_unknown' => 'handled by the flag'
183
+ }
184
+ end
185
+ after { UnknownAttrsBoth.bucket.default_collection.remove doc_id }
186
+
187
+ it "tolerates both without raising, and only warns about the one the flag handled" do
188
+ expect(CouchbaseOrm.logger).to receive(:warn).once.with(a_string_including('other_unknown'))
189
+ model = UnknownAttrsBoth.find(doc_id)
190
+ expect(model.name).to eq('Carol')
191
+ expect(model.attributes.keys).not_to include('legacy_ignored', 'other_unknown')
192
+ end
193
+ end
194
+
195
+ describe "duck-typed, non-Hash input (e.g. ActionController::Parameters)" do
196
+ it "filters through a respond_to?(:each_pair) object just like a Hash" do
197
+ # Calling assign_attributes directly (rather than .new) matters here:
198
+ # Document#initialize's generic branch runs every non-GetResult,
199
+ # non-CouchbaseOrm::Base argument through Kernel#Hash(), which would
200
+ # coerce a real permitted ActionController::Parameters (it defines
201
+ # #to_hash) before assign_attributes ever saw it - masking exactly
202
+ # the guard this test means to exercise.
203
+ model = UnknownAttrsTolerant.new(name: 'Dave')
204
+ model.assign_attributes(EachPairOnly.new(name: 'Dave Updated', nope: 'x'))
205
+ expect(model.name).to eq('Dave Updated')
206
+ expect(model.respond_to?(:nope)).to be false
207
+ end
208
+
209
+ it "still raises for a non-Hash, non-each_pair argument, same as before this feature existed" do
210
+ # Persistence#assign_attributes (unrelated to raise_on_unknown_attributes,
211
+ # pre-existing on CouchbaseOrm::Base) calls `hash.except("type")`
212
+ # unconditionally, so on Base this is a NoMethodError rather than the
213
+ # ArgumentError plain ActiveModel would raise. Either way, it must not
214
+ # be silently swallowed by the unknown-attributes filter.
215
+ expect { UnknownAttrsTolerant.new.assign_attributes(Object.new) }.to raise_error(StandardError)
216
+ end
217
+ end
218
+
219
+ describe "attributes=" do
220
+ it "goes through the same filtering as assign_attributes" do
221
+ model = UnknownAttrsTolerant.new(name: 'Eve')
222
+ model.attributes = { name: 'Eve Updated', nope: 'x' }
223
+ expect(model.name).to eq('Eve Updated')
224
+ expect(model.respond_to?(:nope)).to be false
225
+ end
226
+ end
227
+ end
@@ -1,3 +1,4 @@
1
+ require 'couchbase-orm'
1
2
  require 'couchbase-orm/utilities/ignored_properties'
2
3
 
3
4
  class DummyClass
@@ -17,4 +18,20 @@ RSpec.describe CouchbaseOrm::IgnoredProperties do
17
18
  expect(DummyClass2.ignored_properties).to be_empty
18
19
  end
19
20
  end
21
+
22
+ describe '#ignored_properties' do
23
+ # A fresh class per example, so the deprecated-setter test below can't
24
+ # leak state into (or depend on run order with) any other example.
25
+ let(:klass) { Class.new { extend CouchbaseOrm::IgnoredProperties } }
26
+
27
+ it 'defaults to an empty array' do
28
+ expect(klass.ignored_properties).to eq([])
29
+ end
30
+
31
+ it 'is deprecated as a setter and warns when called with arguments' do
32
+ expect(CouchbaseOrm.logger).to receive(:warn).with(a_string_including('deprecated'))
33
+ klass.ignored_properties(:legacy_property)
34
+ expect(klass.ignored_properties).to eq(['legacy_property'])
35
+ end
36
+ end
20
37
  end
metadata CHANGED
@@ -1,16 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: couchbase-orm
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen von Takach
8
8
  - Gauthier Monserand
9
9
  - Pierre Merlin
10
10
  - Julien Burnet-Fauche
11
+ autorequire:
11
12
  bindir: bin
12
13
  cert_chain: []
13
- date: 1980-01-02 00:00:00.000000000 Z
14
+ date: 2026-09-29 00:00:00.000000000 Z
14
15
  dependencies:
15
16
  - !ruby/object:Gem::Dependency
16
17
  name: activemodel
@@ -209,11 +210,13 @@ dependencies:
209
210
  - !ruby/object:Gem::Version
210
211
  version: '0'
211
212
  description: A Couchbase ORM for Rails
213
+ email:
212
214
  executables: []
213
215
  extensions: []
214
216
  extra_rdoc_files: []
215
217
  files:
216
218
  - ".github/workflows/deploy.yml"
219
+ - ".github/workflows/release.yml"
217
220
  - ".github/workflows/test.yml"
218
221
  - ".gitignore"
219
222
  - ".rspec"
@@ -297,6 +300,7 @@ files:
297
300
  - lib/couchbase-orm/types/encrypted.rb
298
301
  - lib/couchbase-orm/types/nested.rb
299
302
  - lib/couchbase-orm/types/timestamp.rb
303
+ - lib/couchbase-orm/unknown_attributes.rb
300
304
  - lib/couchbase-orm/utilities/ensure_unique.rb
301
305
  - lib/couchbase-orm/utilities/enum.rb
302
306
  - lib/couchbase-orm/utilities/has_many.rb
@@ -334,6 +338,7 @@ files:
334
338
  - spec/json_schema_spec.rb
335
339
  - spec/n1ql_spec.rb
336
340
  - spec/persistence_spec.rb
341
+ - spec/proxies/bucket_proxy_spec.rb
337
342
  - spec/relation_nested_spec.rb
338
343
  - spec/relation_spec.rb
339
344
  - spec/support.rb
@@ -341,6 +346,7 @@ files:
341
346
  - spec/type_encrypted_spec.rb
342
347
  - spec/type_nested_spec.rb
343
348
  - spec/type_spec.rb
349
+ - spec/unknown_attributes_spec.rb
344
350
  - spec/utilities/ignored_properties_spec.rb
345
351
  - spec/utilities/properties_always_exists_in_document_spec.rb
346
352
  - spec/views_spec.rb
@@ -352,6 +358,7 @@ metadata:
352
358
  bug_tracker_uri: https://github.com/Couchbase-Ecosystem/couchbase-ruby-orm/issues
353
359
  documentation_uri: https://www.couchbase-ruby-orm.com/
354
360
  homepage_uri: https://github.com/Couchbase-Ecosystem/couchbase-ruby-orm
361
+ post_install_message:
355
362
  rdoc_options: []
356
363
  require_paths:
357
364
  - lib
@@ -366,7 +373,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
366
373
  - !ruby/object:Gem::Version
367
374
  version: '0'
368
375
  requirements: []
369
- rubygems_version: 4.0.10
376
+ rubygems_version: 3.0.3.1
377
+ signing_key:
370
378
  specification_version: 4
371
379
  summary: Couchbase ORM for Rails
372
380
  test_files:
@@ -392,6 +400,7 @@ test_files:
392
400
  - spec/json_schema_spec.rb
393
401
  - spec/n1ql_spec.rb
394
402
  - spec/persistence_spec.rb
403
+ - spec/proxies/bucket_proxy_spec.rb
395
404
  - spec/relation_nested_spec.rb
396
405
  - spec/relation_spec.rb
397
406
  - spec/support.rb
@@ -399,6 +408,7 @@ test_files:
399
408
  - spec/type_encrypted_spec.rb
400
409
  - spec/type_nested_spec.rb
401
410
  - spec/type_spec.rb
411
+ - spec/unknown_attributes_spec.rb
402
412
  - spec/utilities/ignored_properties_spec.rb
403
413
  - spec/utilities/properties_always_exists_in_document_spec.rb
404
414
  - spec/views_spec.rb