Skip to content

Commit 636d533

Browse files
authored
MONGOID-5228 disallow _id to be updated on persisted documents (#5542)
* MONGOID-5228 - disallow _id to be updated on persisted documents Previously, updating _id on any document (top-level or embedded) would silently ignore the _id attribute, making it appear as though the operation succeeded. This was misleading, since the _id didn't actually change. This change makes it so that _id is explicitly made immutable once a document has been persisted. The same behavior is intentionally enforced regardless of whether the document is embedded or not. If you find yourself needing to update the _id field in an embedded document, you'll need to use the lower-level driver methods to accomplish this. We believe modifying the _id field (even in embedded documents) is an anti-pattern. * use a specialized error for immutability violations * add a feature flag * change the flag name, and account for existing nested attribute behavior * wrong default for <9.0 * clean up a test that was unnecessarily sensitive to the _id changing * another test that was unnecessarily sensitive to _id changes * more tests that are too sensitive to _id changes * we need to allow _id mutations if the _id was previously nil * mention enforcement of _id immutability in the release notes
1 parent 59ef765 commit 636d533

File tree

17 files changed

+371
-86
lines changed

17 files changed

+371
-86
lines changed

docs/release-notes/mongoid-9.0.txt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,25 @@ This means that, by default, Mongoid 9 will update the existing document and
204204
will not replace it.
205205

206206

207+
The immutability of the ``_id`` field is now enforced
208+
-----------------------------------------------------
209+
210+
Prior to Mongoid 9.0, mutating the ``_id`` field behaved inconsistently
211+
depending on whether the document was top-level or embedded, and depending on
212+
how the update was performed. As of 9.0, changing the ``_id`` field will now
213+
raise an exception when the document is saved, if the document had been
214+
previously persisted.
215+
216+
Mongoid 9.0 also introduces a new feature flag, ``immutable_ids``, which
217+
defaults to ``true``.
218+
219+
.. code-block:: ruby
220+
221+
Mongoid::Config.immutable_ids = true
222+
223+
When set to false, the older, inconsistent behavior is restored.
224+
225+
207226
Bug Fixes and Improvements
208227
--------------------------
209228

lib/config/locales/en.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ en:
119119
summary: "Your mongoid.yml configuration file appears to be empty."
120120
resolution: "Ensure your configuration file contains the correct contents.
121121
Refer to: https://www.mongodb.com/docs/mongoid/current/reference/configuration/"
122+
immutable_attribute:
123+
message: "Attempted to change the immutable attribute '%{name}' with
124+
the value: %{value}."
125+
summary: "Immutable attributes can only have values set when the
126+
document is a new record."
127+
resolution: "Do not attempt to update the value of '%{name}' after
128+
the document is persisted."
122129
invalid_collection:
123130
message: "Access to the collection for %{klass} is not allowed."
124131
summary: "%{klass}.collection was called, and %{klass} is an embedded

lib/mongoid/association/nested/one.rb

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def build(parent)
3232
parent.send(association.setter, Factory.build(@class_name, attributes))
3333
elsif delete?
3434
parent.send(association.setter, nil)
35+
else
36+
check_for_id_violation!
3537
end
3638
end
3739

@@ -54,6 +56,17 @@ def initialize(association, attributes, options)
5456

5557
private
5658

59+
# Extracts and converts the id to the expected type.
60+
#
61+
# @return [ BSON::ObjectId | String | Object | nil ] The converted id,
62+
# or nil if no id is present in the attributes hash.
63+
def extracted_id
64+
@extracted_id ||= begin
65+
id = association.klass.extract_id_field(attributes)
66+
convert_id(existing.class, id)
67+
end
68+
end
69+
5770
# Is the id in the attributes acceptable for allowing an update to
5871
# the existing association?
5972
#
@@ -64,8 +77,7 @@ def initialize(association, attributes, options)
6477
#
6578
# @return [ true | false ] If the id part of the logic will allow an update.
6679
def acceptable_id?
67-
id = association.klass.extract_id_field(attributes)
68-
id = convert_id(existing.class, id)
80+
id = extracted_id
6981
existing._id == id || id.nil? || (existing._id != id && update_only?)
7082
end
7183

@@ -110,6 +122,32 @@ def replace?
110122
def update?
111123
existing && !destroyable? && acceptable_id?
112124
end
125+
126+
# Checks to see if the _id attribute (which is supposed to be
127+
# immutable) is being asked to change. If so, raise an exception.
128+
#
129+
# If Mongoid::Config.immutable_ids is false, this will do nothing,
130+
# and the update operation will fail silently.
131+
#
132+
# @raise [ Errors::ImmutableAttribute ] if _id has changed, and
133+
# the document has been persisted.
134+
def check_for_id_violation!
135+
# look for the basic criteria of an update (see #update?)
136+
return unless existing&.persisted? && !destroyable?
137+
138+
# if the id is either absent, or if it equals the existing record's
139+
# id, there is no immutability violation.
140+
id = extracted_id
141+
return if existing._id == id || id.nil?
142+
143+
# otherwise, an attempt has been made to set the _id of an existing,
144+
# persisted document.
145+
if Mongoid::Config.immutable_ids
146+
raise Errors::ImmutableAttribute.new(:_id, id)
147+
else
148+
Mongoid::Warnings.warn_mutable_ids
149+
end
150+
end
113151
end
114152
end
115153
end

lib/mongoid/config.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ module Config
151151
# reload, but when it is turned off, it won't be.
152152
option :legacy_readonly, default: false
153153

154+
# When this flag is true, any attempt to change the _id of a persisted
155+
# document will raise an exception (`Errors::ImmutableAttribute`).
156+
# This is the default in 9.0. Setting this flag to false restores the
157+
# pre-9.0 behavior, where changing the _id of a persisted
158+
# document might be ignored, or it might work, depending on the situation.
159+
option :immutable_ids, default: true
160+
154161
# Returns the Config singleton, for use in the configure DSL.
155162
#
156163
# @return [ self ] The Config singleton.

lib/mongoid/config/defaults.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,16 @@ def load_defaults(version)
4646
when "7.5"
4747
# flags introduced in 8.0 - old functionality
4848
self.map_big_decimal_to_decimal128 = false
49+
50+
load_defaults "8.0"
4951
when "8.0"
50-
# All flag defaults currently reflect 8.0 behavior.
5152
self.legacy_readonly = true
53+
54+
load_defaults "8.1"
5255
when "8.1"
53-
# All flag defaults currently reflect 8.1 behavior.
56+
self.immutable_ids = false
57+
58+
load_defaults "9.0"
5459
when "9.0"
5560
# All flag defaults currently reflect 9.0 behavior.
5661
else

lib/mongoid/errors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require "mongoid/errors/document_not_destroyed"
1010
require "mongoid/errors/document_not_found"
1111
require "mongoid/errors/empty_config_file"
12+
require "mongoid/errors/immutable_attribute"
1213
require "mongoid/errors/in_memory_collation_not_supported"
1314
require "mongoid/errors/invalid_async_query_executor"
1415
require "mongoid/errors/invalid_collection"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Mongoid
4+
module Errors
5+
6+
# This error is raised when attempting the change the value of an
7+
# immutable attribute. For example, the _id attribute is immutable,
8+
# and attempting to change it on a document that has already been
9+
# persisted will result in this error.
10+
class ImmutableAttribute < MongoidError
11+
12+
# Create the new error.
13+
#
14+
# @example Create the new error.
15+
# ImmutableAttribute.new(:_id, "1234")
16+
#
17+
# @param [ Symbol | String ] name The name of the attribute.
18+
# @param [ Object ] value The attempted set value.
19+
def initialize(name, value)
20+
super(
21+
compose_message("immutable_attribute", { name: name, value: value })
22+
)
23+
end
24+
end
25+
end
26+
end

lib/mongoid/persistable/updatable.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def init_atomic_updates
9797
# @return [ true | false ] The result of the update.
9898
def prepare_update(options = {})
9999
raise Errors::ReadonlyDocument.new(self.class) if readonly? && !Mongoid.legacy_readonly
100+
enforce_immutability_of_id_field!
100101
return false if performing_validations?(options) &&
101102
invalid?(options[:context] || :update)
102103
process_flagged_destroys
@@ -186,6 +187,31 @@ def process_touch_option(options, children)
186187
children.each(&:timeless)
187188
end
188189
end
190+
191+
# Checks to see if the _id field has been modified. If it has, and if
192+
# the document has already been persisted, this is an error. Otherwise,
193+
# returns without side-effects.
194+
#
195+
# Note that if `Mongoid::Config.immutable_ids` is false, this will do
196+
# nothing.
197+
#
198+
# @raise [ Errors::ImmutableAttribute ] if _id has changed, and document
199+
# has been persisted.
200+
def enforce_immutability_of_id_field!
201+
# special case here: we *do* allow the _id to be mutated if it was
202+
# previously nil. This addresses an odd case exposed in
203+
# has_one/proxy_spec.rb where `person.create_address` would
204+
# (somehow?) create the address with a nil _id first, before then
205+
# saving it *again* with the correct _id.
206+
207+
if _id_changed? && !_id_was.nil? && persisted?
208+
if Mongoid::Config.immutable_ids
209+
raise Errors::ImmutableAttribute.new(:_id, _id)
210+
else
211+
Mongoid::Warnings.warn_mutable_ids
212+
end
213+
end
214+
end
189215
end
190216
end
191217
end

lib/mongoid/warnings.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ def warning(id, message)
2525
warning :as_json_compact_deprecated, '#as_json :compact option is deprecated. Please call #compact on the returned Hash object instead.'
2626
warning :symbol_type_deprecated, 'The BSON Symbol type is deprecated by MongoDB. Please use String or StringifiedSymbol field types instead of the Symbol field type.'
2727
warning :legacy_readonly, 'The readonly! method will only mark the document readonly when the legacy_readonly feature flag is switched off.'
28+
warning :mutable_ids, 'Ignoring updates to immutable attribute `_id`. Please set Mongoid::Config.immutable_ids to true and update your code so that `_id` is never updated.'
2829
end
2930
end

spec/mongoid/association/embedded/embeds_many/proxy_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4566,7 +4566,7 @@ class TrackingIdValidationHistory
45664566
before do
45674567
band.collection.
45684568
find(_id: band.id).
4569-
update_one("$set" => { records: [{ name: "Moderat" }]})
4569+
update_one("$set" => { records: [{ _id: BSON::ObjectId.new, name: "Moderat" }]})
45704570
end
45714571

45724572
context "when loading the documents" do

0 commit comments

Comments
 (0)