Skip to content

Commit 102cb44

Browse files
authored
MONGOID-5228 disallow _id to be updated on persisted documents (ported to 8.1-stable) (#5545)
* port #5542 to 8.1-stable * test tweak * update 8.1 release notes to mention immutable_ids
1 parent f06d5ab commit 102cb44

File tree

17 files changed

+349
-85
lines changed

17 files changed

+349
-85
lines changed

docs/release-notes/mongoid-8.1.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,23 @@ criteria in a ``$nor`` operation. For example:
398398

399399
This would query all buildings in Portland, excluding apartments, buildings less than
400400
100 units tall, and buildings with an occupancy greater than 2500 people.
401+
402+
403+
Added ``Mongoid::Config.immutable_ids``
404+
---------------------------------------
405+
406+
Coming in Mongoid 9.0, the ``_id`` field will be immutable in both top-level
407+
and embedded documents. This addresses some inconsistency in how mutations
408+
to the ``_id`` field are treated currently. To prepare for this potentially
409+
breaking change, the ``Mongoid::Config.immutable_ids`` flag has been added. It
410+
defaults to ``false``, preserving the existing behavior, but you may set it to
411+
``true`` to prepare your apps for Mongoid 9.0. When this is set to ``true``,
412+
attempts to mutate the ``_id`` of a document will raise an exception.
413+
414+
.. code:: ruby
415+
416+
# The default in Mongoid 8.1
417+
Mongoid::Config.immutable_ids = false
418+
419+
# The default in Mongoid 9.0
420+
Mongoid::Config.immutable_ids = true

lib/config/locales/en.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ en:
108108
summary: "Your mongoid.yml configuration file appears to be empty."
109109
resolution: "Ensure your configuration file contains the correct contents.
110110
Refer to: https://www.mongodb.com/docs/mongoid/current/reference/configuration/"
111+
immutable_attribute:
112+
message: "Attempted to change the immutable attribute '%{name}' with
113+
the value: %{value}."
114+
summary: "Immutable attributes can only have values set when the
115+
document is a new record."
116+
resolution: "Do not attempt to update the value of '%{name}' after
117+
the document is persisted."
111118
invalid_collection:
112119
message: "Access to the collection for %{klass} is not allowed."
113120
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
@@ -150,6 +150,13 @@ module Config
150150
# reload, but when it is turned off, it won't be.
151151
option :legacy_readonly, default: true
152152

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

lib/mongoid/errors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "mongoid/errors/document_not_destroyed"
99
require "mongoid/errors/document_not_found"
1010
require "mongoid/errors/empty_config_file"
11+
require "mongoid/errors/immutable_attribute"
1112
require "mongoid/errors/in_memory_collation_not_supported"
1213
require "mongoid/errors/invalid_async_query_executor"
1314
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
@@ -184,6 +185,31 @@ def process_touch_option(options, children)
184185
children.each(&:timeless)
185186
end
186187
end
188+
189+
# Checks to see if the _id field has been modified. If it has, and if
190+
# the document has already been persisted, this is an error. Otherwise,
191+
# returns without side-effects.
192+
#
193+
# Note that if `Mongoid::Config.immutable_ids` is false, this will do
194+
# nothing.
195+
#
196+
# @raise [ Errors::ImmutableAttribute ] if _id has changed, and document
197+
# has been persisted.
198+
def enforce_immutability_of_id_field!
199+
# special case here: we *do* allow the _id to be mutated if it was
200+
# previously nil. This addresses an odd case exposed in
201+
# has_one/proxy_spec.rb where `person.create_address` would
202+
# (somehow?) create the address with a nil _id first, before then
203+
# saving it *again* with the correct _id.
204+
205+
if _id_changed? && !_id_was.nil? && persisted?
206+
if Mongoid::Config.immutable_ids
207+
raise Errors::ImmutableAttribute.new(:_id, _id)
208+
else
209+
Mongoid::Warnings.warn_mutable_ids
210+
end
211+
end
212+
end
187213
end
188214
end
189215
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, 'In Mongoid 9.0 the _id field will be immutable. In earlier versions of 8.x, mutating the _id field was supported inconsistently. Prepare your code for 9.0 by setting Mongoid::Config.immutable_ids to true.'
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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ class << person
971971
before do
972972
band.collection.
973973
find(_id: band.id).
974-
update_one("$set" => { label: { name: "Mute" }})
974+
update_one("$set" => { label: { _id: BSON::ObjectId.new, name: "Mute" }})
975975
end
976976

977977
context "when loading the documents" do

0 commit comments

Comments
 (0)