Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on: [push, pull_request]

jobs:
build:
# disable rubocop on legacy branches
if: false

runs-on: ubuntu-latest
env:
CI: true
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ name: Run Mongoid Tests
- pull_request
jobs:
build:
# skip github workflows on this branch; we'll rely solely on
# evergreen for CI runs
if: false

name: "${{matrix.ruby}} db:${{matrix.mongodb}}
rails:${{matrix.rails}} fle:${{matrix.fle}} ${{matrix.topology}}"
env:
Expand Down
2 changes: 1 addition & 1 deletion .mod/drivers-evergreen-tools
2 changes: 1 addition & 1 deletion gemfiles/standard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ def standard_dependencies
end

if ENV['FLE'] == 'helper'
gem 'libmongocrypt-helper', '~> 1.14.0'
gem 'libmongocrypt-helper', '~> 1.20.0'
end
end
45 changes: 45 additions & 0 deletions lib/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,32 @@ en:
A collation option is only supported if the query is executed on a MongoDB server
with version >= 3.4."
resolution: "Remove the collation option from the query."
in_memory_regexp_timeout:
message: "Evaluating this query in memory exceeded the %{limit} second
limit that applies to conditions containing a regular expression."
summary: "Mongoid evaluates some conditions in the calling thread
rather than sending them to the server: queries against an embedded
association, removing documents from an association by condition,
and Document#_matches?. A regular expression in such a condition
runs locally. Mongoid limits the total time one in-memory
evaluation may spend matching, because both the cost of a single
match and the number of matches performed grow with the condition,
and a pattern built from end-user input can be made to consume an
unbounded amount of CPU. The limit is cumulative over the whole
evaluation rather than per match. Where the Ruby in use offers no
per-Regexp timeout the limit is enforced as wall clock over the
whole in-memory evaluation, so there it can be exceeded by a large
scan even when the pattern itself is cheap."
resolution: "Check whether the pattern in this query comes from user
input; if it does, validate it before querying. If the query is
legitimate and simply large, raise
Mongoid::Config.in_memory_regexp_time_limit, or set it to nil to
remove the limit entirely. The limit in force is the smaller of
that setting and any global Regexp.timeout the application has set,
so check both. On JRuby, and on MRI before 3.2, there is no
per-Regexp timeout, so what the limit bounds there is the elapsed
time of the whole in-memory evaluation rather than the time spent
matching."
invalid_around_callback:
message: "An around callback must contain a yield in its definition."
summary: "The block needs to be yielded to for around callbacks to function as intended."
Expand Down Expand Up @@ -515,6 +541,25 @@ en:
environment, Mongoid cannot load its configuration."
resolution: "Please ensure an environment is set in one of the
listed locations. The environment must be explicitly set."
no_encryption_schema:
message: "The encryption schema of client %{client} does not cover
%{namespace}."
summary: "%{klass} declares encrypted fields, but the namespace it
resolved to, %{namespace}, is not part of the automatic encryption
schema of client %{client}. The encryption schema is built once,
when the client is created, and is keyed by namespace, so a
database name that is only known later - a callable :database
option, Model.with(database:), Mongoid.override_database - is not
in it. Routing the model to a client that has no
auto_encryption_options has the same effect. Mongoid refuses the
operation because the driver would store the declared fields in
plaintext without reporting an error."
resolution: "Give the model a namespace that is known when the
client is created, and a client configured with
auto_encryption_options. If the model has to live in a database
chosen at runtime, configure one client per database, each with
its own auto_encryption_options, and select between them with
Model.with(client:) instead of switching the database."
no_map_reduce_output:
message: "No output location was specified for the map/reduce
operation."
Expand Down
12 changes: 8 additions & 4 deletions lib/mongoid/association/depending.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ module Depending
included do
class_attribute :dependents

# Note the leading underscore: without it, ActiveSupport's
# class_attribute would generate a helper method for this attribute
# that collides with one it generates for :dependents.
#
# @api private
class_attribute :dependents_owner
class_attribute :_dependents_owner

self.dependents = []
self.dependents_owner = self
self._dependents_owner = self
end

class_methods do
Expand Down Expand Up @@ -57,9 +61,9 @@ def _all_dependents
def self.define_dependency!(association)
validate!(association)
association.inverse_class.tap do |klass|
if klass.dependents_owner != klass
if klass._dependents_owner != klass
klass.dependents = []
klass.dependents_owner = klass
klass._dependents_owner = klass
end

if association.dependent && !klass.dependents.include?(association)
Expand Down
39 changes: 34 additions & 5 deletions lib/mongoid/association/nested/many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def update_document(doc, attrs)
# @param [ Hash ] attrs The single document attributes to process.
def update_nested_relation(parent, id, attrs)
first = existing.first
converted = first ? convert_id(first.class, id) : id
converted = convert_id(first ? first.class : association.klass, id)

if existing.where(_id: converted).exists?
# document exists in association
Expand All @@ -186,19 +186,48 @@ def update_nested_relation(parent, id, attrs)
end
elsif association.embedded?
raise Errors::DocumentNotFound.new(association.klass, id)
elsif association.is_a?(Association::Referenced::HasAndBelongsToMany) || Mongoid.allow_reparenting_via_nested_attributes?
Mongoid::Warnings.warn_reparenting_via_nested_attributes if Mongoid.allow_reparenting_via_nested_attributes?
elsif destroyable?(attrs)
# A destroy of a document that is not in the association is
# ignored, rather than reaching for it outside the association.
nil
elsif Mongoid.allow_reparenting_via_nested_attributes?
Mongoid::Warnings.warn_reparenting_via_nested_attributes

# push existing document to association
doc = association.klass.unscoped.find(converted)
# find returns nil instead of raising when
# Mongoid.raise_not_found_error is false; a missing document
# must still be an error here, consistent with the other
# not-found branches.
raise Errors::DocumentNotFound.new(association.klass, id) if doc.nil?

update_document(doc, attrs)
existing.push(doc) unless destroyable?(attrs)
existing.push(doc)
else
raise Errors::DocumentNotFound.new(association.klass, { _id: id, association.foreign_key => parent.id })
raise Errors::DocumentNotFound.new(association.klass, not_found_params(parent, id))
end

parent.children_may_have_changed!
end

# The params to report when an id in the nested attributes could not
# be resolved within the association.
#
# @api private
#
# @param [ Document ] parent The parent document.
# @param [ String | BSON::ObjectId ] id of the related document.
#
# @return [ Hash | Object ] The params for the not found error.
def not_found_params(parent, id)
if association.is_a?(Association::Referenced::HasAndBelongsToMany)
# A many-to-many association has no foreign key on the child, so
# the id is only ever resolved within the association itself.
id
else
{ _id: id, association.foreign_key => parent.id }
end
end
end
end
end
Expand Down
14 changes: 14 additions & 0 deletions lib/mongoid/association/nested/nested_buildable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,26 @@ def update_only?
# @example Convert the id.
# builder.convert_id(Person, "4d371b444835d98b8b000010")
#
# Ids arriving from a form are always scalars. A Hash or an Array
# here means the parameters were crafted, and letting one through
# would turn the id into a query operator, so they are rejected.
#
# @param [ Class ] klass The class we're trying to convert for.
# @param [ String ] id The id, usually coming from the form.
#
# @return [ BSON::ObjectId | String | Object ] The converted id.
#
# @raise [ Errors::DocumentNotFound ] if the id is not a scalar, or
# cannot be converted to the type the class uses for its ids.
# The BSON::Error rescue is defensive: a value that reaches
# BSON::ObjectId.mongoize and raises there must not surface as an
# unhandled BSON error in the caller.
def convert_id(klass, id)
raise Errors::DocumentNotFound.new(klass, id) if id.is_a?(::Hash) || id.is_a?(::Array)

klass.using_object_ids? ? BSON::ObjectId.mongoize(id) : id
rescue BSON::Error
raise Errors::DocumentNotFound.new(klass, id)
end

private
Expand Down
101 changes: 100 additions & 1 deletion lib/mongoid/association/referenced/has_many/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -534,12 +534,111 @@ def persistable?
# @return [ Integer ] The number of documents deleted.
def remove_all(conditions = nil, method = :delete_all)
selector = conditions || {}
removed = klass.send(method, selector.merge!(criteria.selector))
selector.merge!(criteria.selector)

# Scanning before the delete means scanning only what is already in
# memory, so it is only done where there is a pattern whose cost has
# to be bounded. Everywhere else the delete comes first and the scan
# sees the survivors, which is what this association has always
# done.
#
# The limit is read once and carried into whichever branch is taken.
# Letting the branch below ask again would read it a second time,
# across a server round trip, and a limit that had become positive
# in the meantime would put a deadline on the delete_if -- a query,
# and an unbind of every match -- which is the arrangement this
# branch exists to avoid.
limit = Matcher::RegexpBudget.limit_for(selector)
return remove_all_bounded(selector, method, limit) if limit

removed = klass.send(method, selector)

# No scope around this. The scan runs after the delete, so the
# association this loads comes back holding only what the delete did
# not match, and there is no pattern here to bound in any case.
# Opening a scope would save _matches? from deciding once per
# document -- 0.48us against 1.74us for the match itself -- but a
# scope with nothing to bound suppresses that decision for
# everything nested inside it, and loading the association runs
# find callbacks. A pattern in a selector one of those evaluates
# would go unguarded, which costs more than the saving is worth on a
# path that has just made two round trips.
_target.delete_if do |doc|
doc._matches?(selector).tap do |b|
unbind_one(doc) if b
end
end

removed
end

# Deletes all related documents matching a selector that carries a
# regular expression, with the whole scan under one regexp budget.
#
# The scan runs before anything is deleted, so a budget that runs out
# leaves both the database and the association untouched rather than
# reporting a failure for a delete that has already happened. It also
# means the budget is closed by the time the association is mutated,
# so where the budget is enforced with Timeout there is no window for
# the exception to land in the middle of an unbind.
#
# @param [ Hash ] selector The selector to delete with.
# @param [ Symbol ] method The deletion method to call.
# @param [ Float ] limit The seconds the scan may spend, as read by
# the caller when it chose this path.
#
# @return [ Integer ] The number of documents deleted.
def remove_all_bounded(selector, method, limit)
# Only what the association already holds. Iterating it instead
# would load the whole association before the delete, and for a
# broad pattern that is every document the association has: /.*/ is
# both the cheapest pattern an attacker can supply and the one that
# matches everything, so bounding the cost of the matching would
# have been paid for with an unbounded amount of memory. Nothing is
# missed by not loading, because the server evaluates the same
# selector for the delete itself.
#
# It also means the scan runs no query, so no deadline of ours can
# land on one. Where the budget is enforced with Timeout, covering
# a query would report a slow network as a regexp timeout and could
# deliver the asynchronous exception inside the driver's socket
# read.
documents = _target.in_memory

matching = Matcher::RegexpBudget.open_with(limit) do
documents.select { |doc| doc._matches?(selector) }
end

removed = klass.send(method, selector)

# The ids are already known to be in memory, so the loaded and added
# documents can be dropped directly. Enumerable#delete would look
# each one up by id and stop at the first hash that has it, which
# removes the wrong instance when both hashes hold one for the same
# id. Enumerable#delete_if drops it from both, as this does, but
# only after load_all!, which is the load this scan exists to avoid.
#
# Which documents get unbound has always depended on which ones
# happened to be in memory, and scanning only what is in memory
# keeps that. It matters for has_and_belongs_to_many, whose
# unbind_one pulls the id out of the foreign key array on _base and
# marks it dirty; the scan used to run after the delete, against an
# association that a cold proxy had just reloaded as holding only
# the survivors, so nothing matched and nothing was unbound.
#
# TODO: unbinding should not depend on what was in memory. Every
# matched document leaves the association, so every one of them
# should be unbound, which would also stop _base's foreign key
# array from keeping ids that no longer resolve. That is a
# behaviour change for has_and_belongs_to_many and belongs in a
# ticket of its own, with a release note; it should not ride along
# on this one.
matching.each do |doc|
unbind_one(doc)
_target._loaded.delete(doc._id)
_target._added.delete(doc._id)
end

removed
end

Expand Down
17 changes: 17 additions & 0 deletions lib/mongoid/association/relatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ def relation_class
end
alias :klass :relation_class

# The class of the association target, or nil when the named class is
# not defined.
#
# An association may name a class that never gets defined. The
# association is then unusable, but its owner still has to be. Callers
# that walk every association of every model, rather than following the
# one the application asked for, use this instead of relation_class.
#
# @return [ Class | nil ] The association objects' class.
#
# @api private
def try_relation_class
relation_class
rescue NameError
nil
end

# The class name of the object owning this association.
#
# @return [ String ] The owning objects' class name.
Expand Down
8 changes: 8 additions & 0 deletions lib/mongoid/collection_configurable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ module ClassMethods
# @raise [ Errors::CreateCollectionFailure ] If collection creation failed.
# @raise [ Errors::DropCollectionFailure ] If an attempt to drop collection failed.
def create_collection(force: false)
Threaded.with_collection_management do
perform_create_collection(force: force)
end
end

private

def perform_create_collection(force:)
if collection_name.empty?
# This is most probably an anonymous class, we ignore them.
return
Expand Down
Loading
Loading