diff --git a/.gitignore b/.gitignore index b3dfb453e..a96439c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ examples .env .env.private* .tool-versions +build diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a32c30be4..d9250b2ee 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 1000` -# on 2023-04-20 23:45:43 UTC using RuboCop version 1.50.0. +# on 2023-08-26 19:42:04 UTC using RuboCop version 1.49.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 121 +# Offense count: 108 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 154 @@ -25,14 +25,14 @@ Metrics/BlockNesting: # Offense count: 17 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 329 + Max: 327 -# Offense count: 54 +# Offense count: 50 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: Max: 33 -# Offense count: 186 +# Offense count: 182 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 87 @@ -48,21 +48,20 @@ Metrics/ParameterLists: MaxOptionalParameters: 4 Max: 6 -# Offense count: 45 +# Offense count: 40 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: Max: 20 -# Offense count: 7 +# Offense count: 3 # Configuration parameters: EnforcedStyleForLeadingUnderscores. # SupportedStylesForLeadingUnderscores: disallowed, required, optional Naming/MemoizedInstanceVariableName: Exclude: - 'lib/mongoid/association/relatable.rb' - 'lib/mongoid/document.rb' - - 'lib/mongoid/traversable.rb' -# Offense count: 25 +# Offense count: 27 RSpec/AnyInstance: Exclude: - 'spec/mongoid/association/referenced/belongs_to/buildable_spec.rb' @@ -81,7 +80,7 @@ RSpec/AnyInstance: RSpec/BeforeAfterAll: Enabled: false -# Offense count: 832 +# Offense count: 833 # Configuration parameters: Prefixes, AllowedPatterns. # Prefixes: when, with, without RSpec/ContextWording: @@ -204,10 +203,11 @@ RSpec/ContextWording: - 'spec/support/immutable_ids.rb' - 'spec/support/shared/time.rb' -# Offense count: 57 +# Offense count: 58 # Configuration parameters: IgnoredMetadata. RSpec/DescribeClass: Exclude: + - 'spec/integration/active_job_spec.rb' - 'spec/integration/app_spec.rb' - 'spec/integration/associations/belongs_to_spec.rb' - 'spec/integration/associations/embedded_dirty_spec.rb' @@ -248,7 +248,7 @@ RSpec/DescribeClass: - 'spec/mongoid/railties/console_sandbox_spec.rb' - 'spec/mongoid/tasks/database_rake_spec.rb' -# Offense count: 133 +# Offense count: 126 RSpec/ExpectInHook: Exclude: - 'spec/integration/associations/embedded_spec.rb' @@ -264,7 +264,6 @@ RSpec/ExpectInHook: - 'spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_persistence_spec.rb' - 'spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_many/binding_spec.rb' - - 'spec/mongoid/association/referenced/has_many/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_one/binding_spec.rb' - 'spec/mongoid/association/referenced/has_one/buildable_spec.rb' - 'spec/mongoid/association/referenced/has_one/proxy_spec.rb' @@ -292,7 +291,7 @@ RSpec/ExpectInHook: - 'spec/mongoid/validatable/presence_spec.rb' - 'spec/support/immutable_ids.rb' -# Offense count: 24 +# Offense count: 25 # Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly. # Include: **/*_spec*rb*, **/spec/**/* RSpec/FilePath: @@ -314,6 +313,7 @@ RSpec/FilePath: - 'spec/mongoid/extensions/raw_value_spec.rb' - 'spec/mongoid/extensions/stringified_symbol_spec.rb' - 'spec/mongoid/loading_spec.rb' + - 'spec/mongoid/railties/bson_object_id_serializer_spec.rb' - 'spec/mongoid/validatable/associated_spec.rb' - 'spec/mongoid/validatable/format_spec.rb' - 'spec/mongoid/validatable/length_spec.rb' @@ -322,7 +322,7 @@ RSpec/FilePath: - 'spec/mongoid/validatable/uniqueness_spec.rb' - 'spec/mongoid/version_spec.rb' -# Offense count: 17 +# Offense count: 18 RSpec/IteratedExpectation: Exclude: - 'spec/mongoid/association/referenced/belongs_to/eager_spec.rb' @@ -333,10 +333,12 @@ RSpec/IteratedExpectation: - 'spec/mongoid/contextual/mongo_spec.rb' - 'spec/mongoid/criteria_spec.rb' - 'spec/mongoid/findable_spec.rb' + - 'spec/mongoid_spec.rb' -# Offense count: 229 +# Offense count: 230 RSpec/LeakyConstantDeclaration: Exclude: + - 'spec/integration/active_job_spec.rb' - 'spec/integration/app_spec.rb' - 'spec/integration/callbacks_spec.rb' - 'spec/integration/discriminator_key_spec.rb' @@ -392,7 +394,7 @@ RSpec/LeakyConstantDeclaration: - 'spec/mongoid/validatable/numericality_spec.rb' - 'spec/mongoid/validatable/uniqueness_spec.rb' -# Offense count: 523 +# Offense count: 499 RSpec/LetSetup: Exclude: - 'spec/integration/matcher_examples_spec.rb' @@ -416,7 +418,6 @@ RSpec/LetSetup: - 'spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb' - 'spec/mongoid/association/referenced/has_many/eager_spec.rb' - 'spec/mongoid/association/referenced/has_many/enumerable_spec.rb' - - 'spec/mongoid/association/referenced/has_many/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_many_spec.rb' - 'spec/mongoid/association/referenced/has_one/buildable_spec.rb' - 'spec/mongoid/association/referenced/has_one/eager_spec.rb' @@ -467,8 +468,8 @@ RSpec/LetSetup: - 'spec/mongoid/touchable_spec.rb' - 'spec/mongoid/validatable/uniqueness_spec.rb' -# Offense count: 302 -# Configuration parameters: . +# Offense count: 298 +# Configuration parameters: EnforcedStyle. # SupportedStyles: have_received, receive RSpec/MessageSpies: EnforcedStyle: receive @@ -497,12 +498,12 @@ RSpec/NamedSubject: - 'spec/mongoid/extensions/raw_value_spec.rb' - 'spec/mongoid/matcher/expression_spec.rb' -# Offense count: 5010 +# Offense count: 5025 # Configuration parameters: AllowedGroups. RSpec/NestedGroups: Max: 13 -# Offense count: 33 +# Offense count: 37 # Configuration parameters: AllowedPatterns. # AllowedPatterns: ^expect_, ^assert_ RSpec/NoExpectationExample: @@ -515,6 +516,7 @@ RSpec/NoExpectationExample: - 'spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb' - 'spec/mongoid/attributes_spec.rb' - 'spec/mongoid/collection_configurable_spec.rb' + - 'spec/mongoid/criteria/queryable/storable_spec.rb' - 'spec/mongoid/document_spec.rb' - 'spec/mongoid/errors/mongoid_error_spec.rb' - 'spec/mongoid/persistence_context_spec.rb' @@ -563,7 +565,7 @@ RSpec/RepeatedExample: - 'spec/mongoid/factory_spec.rb' - 'spec/mongoid/persistable/savable_spec.rb' -# Offense count: 72 +# Offense count: 68 RSpec/RepeatedExampleGroupBody: Exclude: - 'spec/mongoid/association/embedded/embedded_in_spec.rb' @@ -574,7 +576,6 @@ RSpec/RepeatedExampleGroupBody: - 'spec/mongoid/association/referenced/belongs_to_spec.rb' - 'spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_and_belongs_to_many_spec.rb' - - 'spec/mongoid/association/referenced/has_many/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_many_spec.rb' - 'spec/mongoid/association/referenced/has_one_spec.rb' - 'spec/mongoid/attributes/readonly_spec.rb' @@ -625,20 +626,19 @@ RSpec/ScatteredLet: - 'spec/mongoid/reloadable_spec.rb' - 'spec/mongoid/scopable_spec.rb' -# Offense count: 69 +# Offense count: 67 RSpec/ScatteredSetup: Exclude: - 'spec/integration/stringified_symbol_field_spec.rb' - 'spec/mongoid/association/auto_save_spec.rb' - 'spec/mongoid/association/referenced/belongs_to/proxy_spec.rb' - 'spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb' - - 'spec/mongoid/association/referenced/has_many/proxy_spec.rb' - 'spec/mongoid/attributes/nested_spec.rb' - 'spec/mongoid/changeable_spec.rb' - 'spec/mongoid/copyable_spec.rb' - 'spec/mongoid/persistable/deletable_spec.rb' -# Offense count: 12 +# Offense count: 14 RSpec/StubbedMock: Exclude: - 'spec/mongoid/clients_spec.rb' @@ -676,7 +676,7 @@ RSpec/VerifiedDoubles: - 'spec/mongoid/validatable/associated_spec.rb' - 'spec/rails/mongoid_spec.rb' -# Offense count: 22 +# Offense count: 21 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforceForPrefixed. Rails/Delegate: @@ -687,7 +687,6 @@ Rails/Delegate: - 'lib/mongoid/clients.rb' - 'lib/mongoid/clients/options.rb' - 'lib/mongoid/contextual/memory.rb' - - 'lib/mongoid/contextual/mongo.rb' - 'lib/mongoid/contextual/none.rb' - 'lib/mongoid/criteria.rb' - 'lib/mongoid/document.rb' @@ -716,7 +715,7 @@ Rails/I18nLocaleAssignment: - 'spec/mongoid/validatable/uniqueness_spec.rb' - 'spec/support/macros.rb' -# Offense count: 126 +# Offense count: 129 # Configuration parameters: ForbiddenMethods, AllowedMethods. # ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all Rails/SkipsModelValidations: @@ -798,11 +797,10 @@ Rails/TimeZoneAssignment: - 'spec/support/macros.rb' - 'spec/support/shared/time.rb' -# Offense count: 6 +# Offense count: 5 Style/MultilineBlockChain: Exclude: - 'lib/mongoid/association/eager.rb' - - 'lib/mongoid/association/referenced/has_many/proxy.rb' - 'lib/mongoid/contextual/memory.rb' - 'spec/mongoid/changeable_spec.rb' - 'spec/mongoid/document_spec.rb' @@ -833,7 +831,7 @@ Style/OptionalBooleanParameter: - 'spec/support/models/address.rb' - 'spec/support/models/name.rb' -# Offense count: 242 +# Offense count: 240 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https diff --git a/docs/reference/associations.txt b/docs/reference/associations.txt index 4cdbd63a2..ab3deb411 100644 --- a/docs/reference/associations.txt +++ b/docs/reference/associations.txt @@ -1267,7 +1267,7 @@ The child may become orphaned if it is ordinarily only referenced via the parent label.bands.push(Band.first) label.delete # Raises an error since bands is not empty. - Band.first.delete # Will delete all associated albums. + Band.first.destroy # Will delete all associated albums. Autosaving diff --git a/docs/reference/compatibility.txt b/docs/reference/compatibility.txt index 220f0b742..d02bffc9d 100644 --- a/docs/reference/compatibility.txt +++ b/docs/reference/compatibility.txt @@ -32,7 +32,7 @@ specified Mongoid versions. :class: compatibility-large no-padding * - Mongoid - - Driver 2.18 + - Driver 2.19-2.18 - Driver 2.17-2.10 - Driver 2.9-2.7 @@ -83,7 +83,7 @@ is deprecated. - |checkmark| - |checkmark| - D - - + - D - - - @@ -97,13 +97,13 @@ is deprecated. - |checkmark| - |checkmark| - |checkmark| - - D + - |checkmark| + - - - - - - |checkmark| - - D - * - 8.0 @@ -250,6 +250,7 @@ and will be removed in a next version. :class: compatibility-large no-padding * - Mongoid + - MongoDB 7.0 - MongoDB 6.0 - MongoDB 5.0 - MongoDB 4.4 @@ -262,6 +263,7 @@ and will be removed in a next version. - MongoDB 2.6 * - 9.0 + - |checkmark| - |checkmark| - |checkmark| - D @@ -304,12 +306,15 @@ and will be removed in a next version. - |checkmark| - |checkmark| - |checkmark| + - |checkmark| - D - D - D - D * - 6.4 thru 7.3 + - + - - - |checkmark| - |checkmark| @@ -353,7 +358,7 @@ are supported by Mongoid. - |checkmark| - |checkmark| - |checkmark| - - D [#rails-5-ruby-3.0]_ + - |checkmark| [#rails-5-ruby-3.0]_ - * - 8.0 diff --git a/docs/reference/configuration.txt b/docs/reference/configuration.txt index 7d1b699c3..af7fa7a95 100644 --- a/docs/reference/configuration.txt +++ b/docs/reference/configuration.txt @@ -813,14 +813,20 @@ the ``before_fork`` hook to close clients in the parent process .. code-block:: ruby on_worker_boot do - Mongoid::Clients.clients.each do |name, client| - client.close - client.reconnect + if defined?(Mongoid) + Mongoid::Clients.clients.each do |name, client| + client.close + client.reconnect + end + else + raise "Mongoid is not loaded. You may have forgotten to enable app preloading." end end before_fork do - Mongoid.disconnect_clients + if defined?(Mongoid) + Mongoid.disconnect_clients + end end Unicorn @@ -833,14 +839,20 @@ the ``before_fork`` hook to close clients in the parent process .. code-block:: ruby after_fork do |server, worker| - Mongoid::Clients.clients.each do |name, client| - client.close - client.reconnect + if defined?(Mongoid) + Mongoid::Clients.clients.each do |name, client| + client.close + client.reconnect + end + else + raise "Mongoid is not loaded. You may have forgotten to enable app preloading." end end before_fork do |server, worker| - Mongoid.disconnect_clients + if defined?(Mongoid) + Mongoid.disconnect_clients + end end Passenger @@ -856,9 +868,11 @@ before the workers are forked. if defined?(PhusionPassenger) PhusionPassenger.on_event(:starting_worker_process) do |forked| - Mongoid::Clients.clients.each do |name, client| - client.close - client.reconnect + if forked + Mongoid::Clients.clients.each do |name, client| + client.close + client.reconnect + end end end end diff --git a/docs/reference/queries.txt b/docs/reference/queries.txt index 36e5004de..1fdd81e4f 100644 --- a/docs/reference/queries.txt +++ b/docs/reference/queries.txt @@ -2377,23 +2377,15 @@ document inserts, updates, and deletion. Query Cache =========== -The Ruby MongoDB driver provide query caching functionality. When enabled, the +The Ruby MongoDB driver provides query caching functionality. When enabled, the query cache saves the results of previously executed find and aggregation queries and reuses them in the future instead of performing the queries again, thus increasing application performance and reducing database load. -The query cache has historically been provided by Mongoid but as of driver -version 2.14.0, the query cache implementation has been moved into the driver. -When Mongoid is used with driver version 2.14.0 or later, Mongoid delegates -all work to the driver's query cache implementation. - Please review the `driver query cache documentation `_ for details about the driver's query cache behavior. -The rest of this section assumes that driver 2.14.0 or later is being used and -Mongoid delegates to the driver's query cache. - Enabling Query Cache -------------------- diff --git a/docs/release-notes/mongoid-8.1.txt b/docs/release-notes/mongoid-8.1.txt index 5febf8c41..132dca49d 100644 --- a/docs/release-notes/mongoid-8.1.txt +++ b/docs/release-notes/mongoid-8.1.txt @@ -260,7 +260,7 @@ Added ``readonly!`` method and ``legacy_readonly`` feature flag --------------------------------------------------------------- Mongoid 8.1 changes the meaning of read-only documents. In Mongoid 8.1 with -this feature flag turned off, a document becomes read-only when calling the +this feature flag set to ``false``, a document becomes read-only when calling the ``readonly!`` method: .. code:: ruby @@ -276,7 +276,7 @@ With this feature flag turned off, a ``ReadonlyDocument`` error will be raised when destroying or deleting, as well as when saving or updating. Prior to Mongoid 8.1 and in 8.1 with the ``legacy_readonly`` feature flag -turned on, documents become read-only when they are projected (i.e. using +set to ``true``, documents become read-only when they are projected (i.e. using ``#only`` or ``#without``). .. code:: ruby diff --git a/docs/release-notes/mongoid-9.0.txt b/docs/release-notes/mongoid-9.0.txt index 5e52492af..7dd57d659 100644 --- a/docs/release-notes/mongoid-9.0.txt +++ b/docs/release-notes/mongoid-9.0.txt @@ -74,9 +74,10 @@ Deprecated functionality removed The following previously deprecated functionality is now removed: -- ``Mongoid::QueryCache`` has been removed. Please replace it 1-for-1 with ``Mongo::QueryCache``. +- The ``Mongoid::QueryCache`` module has been removed. Please replace any usages 1-for-1 with ``Mongo::QueryCache``. The method ``Mongoid::QueryCache#clear_cache`` should be replaced with ``Mongo::QueryCache#clear``. - All other methods and submodules are identically named. + All other methods and submodules are identically named. Refer to the `driver query cache documentation + `_ for more details. - ``Document#as_json :compact`` option is removed. Please call ```#compact`` on the returned ``Hash`` object instead. - ``Criteria#geo_near`` is removed as MongoDB server versions 4.2 @@ -365,6 +366,43 @@ of the ``index`` macro: ``partial_filter_expression``, ``weights``, and Mongoid's functionality may not support such changes. +BSON 5 and BSON::Decimal128 Fields +---------------------------------- + +When BSON 4 or earlier is present, any field declared as BSON::Decimal128 will +return a BSON::Decimal128 value. When BSON 5 is present, however, any field +declared as BSON::Decimal128 will return a BigDecimal value by default. + +.. code-block:: ruby + + class Model + include Mongoid::Document + + field :decimal_field, type: BSON::Decimal128 + end + + # under BSON <= 4 + Model.first.decimal_field.class #=> BSON::Decimal128 + + # under BSON >= 5 + Model.first.decimal_field.class #=> BigDecimal + +If you need literal BSON::Decimal128 values with BSON 5, you may instruct +Mongoid to allow literal BSON::Decimal128 fields: + +.. code-block:: ruby + + Model.first.decimal_field.class #=> BigDecimal + + Mongoid.allow_bson5_decimal128 = true + Model.first.decimal_field.class #=> BSON::Decimal128 + +.. note:: + + The ``allow_bson5_decimal128`` option only has any effect under + BSON 5 and later. BSON 4 and earlier ignore the setting entirely. + + Bug Fixes and Improvements -------------------------- @@ -394,3 +432,6 @@ This section will be for smaller bug fixes and improvements: a non-numeric, non-string value that implements ``:to_d`` will return a string rather than a ``BigDecimal`` `MONGOID-5507 `_. +- Added support for serializing and deserializing BSON::ObjectId values + when passed as ActiveJob arguments + `MONGOID-5611 `_. diff --git a/docs/tutorials.txt b/docs/tutorials.txt index 33ab03954..d9f0a8510 100644 --- a/docs/tutorials.txt +++ b/docs/tutorials.txt @@ -14,3 +14,4 @@ Tutorials tutorials/getting-started-rails6 tutorials/documents tutorials/common-errors + tutorials/automatic-encryption diff --git a/docs/tutorials/automatic-encryption.txt b/docs/tutorials/automatic-encryption.txt new file mode 100644 index 000000000..dd7ce2890 --- /dev/null +++ b/docs/tutorials/automatic-encryption.txt @@ -0,0 +1,435 @@ +******************************************** +Automatic Client-Side Field Level Encryption +******************************************** + +.. default-domain:: mongodb + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + + +Since version 4.2 MongoDB supports `Client-Side Field Level Encryption +(CSFLE) `_. This is a feature +that enables you to encrypt data in your application before you send it over the +network to MongoDB. With CSFLE enabled, no MongoDB product has access to your +data in an unencrypted form. + +You can set up CSFLE using the following mechanisms: + +* `Automatic Encryption `_: + Enables you to perform encrypted read and write operations without you having + to write code to specify how to encrypt fields. +* `Explicit Encryption `_: + Enables you to perform encrypted read and write operations through your + MongoDB driver's encryption library. You must specify the logic for encryption + with this library throughout your application. + +Starting with version 9.0, Mongoid supports CSFLE's Automatic Encryption +feature. This tutorial walks you through the process of setting up and using +CSFLE in Mongoid. + +.. note:: + This tutorial does not cover all CSLFE features. + You can find more information about MongoDB CSFLE in + `the server documentation. `_ + +.. note:: + If you want to use explicit FLE, please follow `the Ruby driver documentation. + `_ + + +Installation +============ + +You can find the detailed description of how to install the necessary +dependencies in `the driver documentation. `_ + +Note the version of the Ruby driver being used in your application and select +the appropriate steps below. + +Install ``libmongocrypt`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +This can be done one of two ways. + +* Add the `libmongocrypt-helper gem `_ to + your ``Gemfile`` or +* Download the ``libmongocrypt`` `release archive `_, + extract the version that matches your operating system, and set the + ``LIBMONGOCRYPT_PATH`` environment variable accordingly. + +Install the automatic encryption shared library (Ruby driver 2.19+) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you use the Ruby driver version 2.19 and above the automatic encryption +shared library should be installed by following the instructions in the +`MongoDB manual `_. + +The steps required are as follows: + +1. Navigate to the `MongoDB Download Center `_ +2. From the Version dropdown, select ``x.y.z (current)`` (the latest current version). +3. In the Platform dropdown, select your platform. +4. In the Package dropdown, select ``crypt_shared``. +5. Click Download. + +Once extracted, ensure the full path to the library is configured within your +``mongoid.yml`` as follows: + +.. code-block:: yaml + + development: + clients: + default: + options: + auto_encryption_options: + extra_options: + crypt_shared_lib_path: '/path/to/mongo_crypt_v1.so' + +Install the ``mongocryptd`` (Ruby driver 2.18 or older) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are using an older version of the Ruby driver ``mongocryptd`` will +need to be installed manually. ``mongocryptd`` comes pre-packaged with +enterprise builds of the MongoDB server (versions 4.2 and newer). +For installation instructions, see the `MongoDB manual `_. + +Add ``ffi`` to your Gemfile +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The MongoDB Ruby driver uses the `ffi gem `_ to call +functions from ``libmongocrypt``. As this gem is not a dependency of +the driver, it will need to be manually added to your ``Gemfile``: + +.. code-block:: ruby + + gem 'ffi' + +Create a Customer Master Key +============================ + +A Customer Master Key (CMK) is used to encrypt Data Encryption Keys. +The easiest way is to use a locally stored 96-bytes key. You can generate such +a key using the following Ruby code: + +.. code-block:: ruby + + require 'securerandom' + + SecureRandom.hex(48) # => "f54ab...." + +Later in this tutorial we assume that the Customer Master Key is +available from the ``CUSTOMER_MASTER_KEY`` environment variable. + +.. warning:: + + Using a local master key is insecure. It is recommended that you use a remote + Key Management Service to create and store your master key. To do so, follow + steps of the `"Set up a Remote Master Key" `_ + in the MongoDB Client-Side Encryption documentation. + + For more information about creating a master key, see the + `Create a Customer Master Key `_ + section of the MongoDB manual. + +Configure Clients +================= + +Automatic CSFLE requires some additional configuration for the MongoDB client. +Assuming that your application has just one ``default`` client, you need to +add the following to your ``mongoid.yml``: + +.. code-block:: yaml + + development: + clients: + default: + uri: mongodb+srv://user:pass@yourcluster.mongodb.net/blog_development?retryWrites=true&w=majority + options: + auto_encryption_options: # This key enables automatic encryption + key_vault_namespace: 'encryption.__keyVault' # Database and collection to store data keys + kms_providers: # Tells the driver where to obtain master keys + local: # We use the local key in this tutorial + key: "<%= ENV['CUSTOMER_MASTER_KEY'] %>" # Key that we generated earlier + extra_options: + crypt_shared_lib_path: '/path/to/mongo_crypt_v1.so' # Only if you use the library + + +Create a Data Encryption Key +============================ + +A Data Encryption Key (DEK) is the key you use to encrypt the fields in your +MongoDB documents. You store your Data Encryption Key in your Key Vault +collection encrypted with your CMK. + +To create a DEK in Mongoid you can use the +``db:mongoid:encryption:create_data_key`` ``Rake`` task: + +.. code-block:: sh + + % rake db:mongoid:encryption:create_data_key + Created data key with id: 'KImyywsTQWi1+cFYIHdtlA==' for kms provider: 'local' in key vault: 'encryption.__keyVault'. + +You can create multiple DEKs, if necessary. + +.. code-block:: sh + + % rake db:mongoid:encryption:create_data_key + Created data key with id: 'Vxr5m+5cQISjDOruzZgE0w==' for kms provider: 'local' in key vault: 'encryption.__keyVault'. + +You can also provide an alternate name for the DEK. This allows you to reference +the DEK by name when configuring encryption for your fields. It also allows you +to dynamically assign a DEK to a field at runtime. + +.. code-block:: sh + + % rake db:mongoid:encryption:create_data_key -- --key-alt-name=my_data_key + Created data key with id: 'yjF8hKmKQsqGeFGXlB9Sow==' with key alt name: 'my_data_key' for kms provider: 'local' in key vault: 'encryption.__keyVault'. + + +Configure Encryption Schema +=========================== + +Now we can tell Mongoid what should be encrypted: + +.. code-block:: ruby + + class Patient + include Mongoid::Document + include Mongoid::Timestamps + + # Tells Mongoid what DEK should be used to encrypt fields of the document + # and its embedded documents. + encrypt_with key_id: 'KImyywsTQWi1+cFYIHdtlA==' + + # This field is not encrypted. + field :category, type: String + + # This field is encrypted using AEAD_AES_256_CBC_HMAC_SHA_512-Random + # algorithm. + field :passport_id, type: String, encrypt: { + deterministic: false + } + # This field is encrypted using AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic + # algorithm. + field :blood_type, type: String, encrypt: { + deterministic: true + } + # This field is encrypted using AEAD_AES_256_CBC_HMAC_SHA_512-Random + # algorithm and using a different data key. + field :ssn, type: Integer, encrypt: { + deterministic: false, key_id: 'Vxr5m+5cQISjDOruzZgE0w==' + } + + embeds_one :insurance + end + + class Insurance + include Mongoid::Document + include Mongoid::Timestamps + + field :insurer, type: String + + # This field is encrypted using AEAD_AES_256_CBC_HMAC_SHA_512-Random + # algorithm using the key which alternate name is stored in the + # policy_number_key field. + field :policy_number, type: Integer, encrypt: { + deterministic: false, + key_name_field: :policy_number_key + } + + embedded_in :patient + end + +.. note:: + If you are developing a Rails application, it is recommended to set + ``preload_models`` to ``true`` in ``mongoid.yml``. This will ensure that + Mongoid loads all models before the application starts, and the encryption + schema is configured before any data is read or written. + +Known Limitations +~~~~~~~~~~~~~~~~~ + +* MongoDB CSFLE has some limitations that are described in + `the server documentation. `_ + These limitations also apply to Mongoid. +* Mongoid does not support encryption of ``embeds_many`` relations. +* If you use ``:key_name_field`` option, the field must be encrypted using + non-deterministic algorithm. To encrypt your field deterministically, you must + specify ``:key_id`` option instead. + +Working with Data +================= + +Automatic CSFLE usage is transparent in many situations. + +.. note:: + In code examples below we assume that there is a variable ``unencrypted_client`` + that is a ``MongoClient`` connected to the same database but without encryption. + We use this client to demonstrate what is actually persisted in the database. + +Documents can be created as usual, fields will be encrypted and decrypted +according to the configuration: + +.. code-block:: ruby + + Patient.create!( + category: 'ER', + passport_id: '123456', + blood_type: 'AB+', + ssn: 98765, + insurance: Insurance.new(insurer: 'TK', policy_number: 123456, policy_number_key: 'my_data_key') + ) + + # Fields are encrypted in the database + unencrypted_client['patients'].find.first + # => + # {"_id"=>BSON::ObjectId('6446a1d046ebfd701f9f4292'), + # "category"=>"ER", + # "passport_id"=>, + # "blood_type"=>, + # "ssn"=>, + # "insurance"=>{"_id"=>BSON::ObjectId('6446a1d046ebfd701f9f4293'), "insurer"=>"TK", "policy_number"=>}, "policy_number_key"=>"my_data_key"} + +Fields encrypted using a deterministic algorithm can be queried. Only exact match +queries are supported. For more details please consult `the server documentation +`_. + +.. code-block:: ruby + + # We can find documents by deterministically encrypted fields. + Patient.where(blood_type: "AB+").to_a + # => [#] + +Encryption Key Management +========================= + +Customer Master Keys +~~~~~~~~~~~~~~~~~~~~ + +Your Customer Master Key is the key you use to encrypt your Data Encryption Keys. +MongoDB automatically encrypts Data Encryption Keys using the specified CMK +during Data Encryption Key creation. + +The CMK is the most sensitive key in CSFLE. If your CMK is compromised, all of +your encrypted data can be decrypted. + +.. important:: + Ensure you store your Customer Master Key (CMK) on a remote KMS. + + To learn more about why you should use a remote KMS, see `Reasons to Use a Remote KMS. `_ + + To view a list of all supported KMS providers, see the `KMS Providers `_ page. + +MongoDB CSFLE supports the following Key Management System (KMS) providers: + * `Amazon Web Services KMS `_ + * `Azure Key Vault `_ + * `Google Cloud Platform KMS `_ + * Any KMIP Compliant Key Management System + * Local Key Provider (for testing only) + +Data Encryption Keys +~~~~~~~~~~~~~~~~~~~~ + +Data Encryption Keys can be created using the +``db:mongoid:encryption:create_data_key`` ``Rake`` task. By default they are +stored on the same cluster as the database. +However, it might be a good idea to store the keys separately. This can be +done by specifying a key vault client in ``mongoid.yml``: + +.. code-block:: yaml + + development: + clients: + key_vault: + uri: mongodb+srv://user:pass@anothercluster.mongodb.net/blog_development?retryWrites=true&w=majority + default: + uri: mongodb+srv://user:pass@yourcluster.mongodb.net/blog_development?retryWrites=true&w=majority + options: + auto_encryption_options: + key_vault_client: :key_vault # Client to connect to key vault + # ... + +Encryption Keys Rotation +~~~~~~~~~~~~~~~~~~~~~~~~ + +You can rotate encryption keys using the ``rewrap_many_data_key`` method +of the Ruby driver. This method automatically decrypts multiple data encryption +keys and re-encrypts them using a specified CMK. It then updates +the rotated keys in the key vault collection. This method allows you to rotate +encryption keys based on two optional arguments: + +* A filter used to specify which keys to rotate. If no data key matches the + given filter, no keys will be rotated. Omit the filter to rotate all keys in + your key vault collection. +* An object that represents a new CMK. Omit this object to rotate the data + keys using their current CMKs. + +Here is an example of rotating keys using AWS KMS: + +.. code-block:: ruby + + # Create a key vault client + key_vault_client = Mongo::Client.new('mongodb+srv://user:pass@yourcluster.mongodb.net') + # Or, if you declared the key value client in mongoid.yml, use it + key_vault_client = Mongoid.client(:key_vault) + + # Create the encryption object + encryption = Mongo::ClientEncryption.new( + key_vault_client, + key_vault_namespace: 'encryption.__keyVault', + kms_providers: { + aws: { + "accessKeyId": "", + "secretAccessKey": "" + } + } + ) + + encryption.rewrap_many_data_key( + {}, # We want to rewrap all keys + { + provider: 'aws', + master_key: { + region: 'us-east-2', + key: 'arn:aws:kms:us-east-2:...' + } + } + ) + +Adding Automatic Encryption To Existing Project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +MongoDB automatic CSFLE supports encryption in place. You can enable encryption +for your existing database, and will still able to read unencrypted data. +All data written to the database will be encrypted. However, as soon as the +encryption is enabled, all query operations will use encrypted data: + +.. code-block:: ruby + + # We assume that there are two documents in the database, one created without + # encryption enabled, and one with encryption. + + # We can still read both. + Patient.all.to_a + # => + # [#, + # #] + + # But when we query, we can see only the latter one. + Patient.where(blood_type: 'AB+').to_a + # => [#] + +If you want to encrypt the existing database, it can be achieved by reading +and writing back all data, even without any changes. If you decide to do so, +please keep the following in mind: + +* Validate the integrity of existing data for consistent fidelity. CSFLE is + type sensitive - for example you cannot store integers in a field that is + declared as ``String``. +* For strings, make sure that empty values are always empty strings or just + not set, but not ``nil`` (CSFLE doesn't support native ``null``). +* This operation requires application downtime. diff --git a/gemfiles/standard.rb b/gemfiles/standard.rb index 2f968db0a..e5c4dd392 100644 --- a/gemfiles/standard.rb +++ b/gemfiles/standard.rb @@ -16,7 +16,8 @@ def standard_dependencies end group :test do - gem 'rspec', '~> 3.10' + gem 'rspec', '~> 3.12' + gem 'activejob' gem 'timecop' gem 'rspec-retry' gem 'benchmark-ips' @@ -36,6 +37,6 @@ def standard_dependencies end if ENV['FLE'] == 'helper' - gem 'libmongocrypt-helper', '~> 1.7.0' + gem 'libmongocrypt-helper', '~> 1.8.0' end end diff --git a/lib/mongoid/association/embedded/embedded_in/proxy.rb b/lib/mongoid/association/embedded/embedded_in/proxy.rb index 28653b35c..d91d446d3 100644 --- a/lib/mongoid/association/embedded/embedded_in/proxy.rb +++ b/lib/mongoid/association/embedded/embedded_in/proxy.rb @@ -24,7 +24,7 @@ class Proxy < Association::One # # @return [ In ] The proxy. def initialize(base, target, association) - init(base, target, association) do + super do characterize_one(_target) bind_one end @@ -71,9 +71,7 @@ def binding # # @param [ Mongoid::Document ] document The document to set the association metadata on. def characterize_one(document) - return if _base._association - - _base._association = _association.inverse_association(document) + _base._association ||= _association.inverse_association(document) end # Are we able to persist this association? diff --git a/lib/mongoid/association/embedded/embeds_many/proxy.rb b/lib/mongoid/association/embedded/embeds_many/proxy.rb index 59a692613..a3afc96fb 100644 --- a/lib/mongoid/association/embedded/embeds_many/proxy.rb +++ b/lib/mongoid/association/embedded/embeds_many/proxy.rb @@ -16,6 +16,68 @@ class EmbedsMany class Proxy < Association::Many include Batchable + # Class-level methods for the Proxy class. + module ClassMethods + + # Returns the eager loader for this association. + # + # @param [ Array ] associations The + # associations to be eager loaded + # @param [ Array ] docs The parent documents + # that possess the given associations, which ought to be + # populated by the eager-loaded documents. + # + # @return [ Mongoid::Association::Embedded::Eager ] + def eager_loader(associations, docs) + Eager.new(associations, docs) + end + + # Returns true if the association is an embedded one. In this case + # always true. + # + # @example Is the association embedded? + # Association::Embedded::EmbedsMany.embedded? + # + # @return [ true ] true. + def embedded? + true + end + + # Returns the suffix of the foreign key field, either "_id" or "_ids". + # + # @example Get the suffix for the foreign key. + # Association::Embedded::EmbedsMany.foreign_key_suffix + # + # @return [ nil ] nil. + def foreign_key_suffix + nil + end + end + + extend ClassMethods + + # Instantiate a new embeds_many association. + # + # @example Create the new association. + # Many.new(person, addresses, association) + # + # @param [ Mongoid::Document ] base The document this association hangs off of. + # @param [ Array ] target The child documents of the association. + # @param [ Mongoid::Association::Relatable ] association The association metadata. + # + # @return [ Many ] The proxy. + def initialize(base, target, association) + super do + _target.each_with_index do |doc, index| + integrate(doc) + doc._index = index + end + update_attributes_hash + @_unscoped = _target.dup + @_target = scope(_target) + end + end + # Appends a document or array of documents to the association. Will set # the parent and update the index in the process. # @@ -28,12 +90,14 @@ class Proxy < Association::Many # @param [ Mongoid::Document... ] *args Any number of documents. def <<(*args) docs = args.flatten + return unless docs.any? return concat(docs) if docs.size > 1 if (doc = docs.first) append(doc) doc.save if persistable? && !_assigning? end + self end @@ -161,6 +225,23 @@ def delete(document) end end + # Mongoid::Extensions::Array defines Array#delete_one, so we need + # to make sure that method behaves reasonably on proxies, too. + alias_method :delete_one, :delete + + # Removes a single document from the collection *in memory only*. + # It will *not* persist the change. + # + # @param [ Document ] document The document to delete. + # + # @api private + def _remove(document) + _target.delete_one(document) + _unscoped.delete_one(document) + update_attributes_hash + reindex + end + # Delete all the documents in the association without running callbacks. # # @example Delete all documents from the association. @@ -183,18 +264,14 @@ def delete_all(conditions = {}) # doc.state == "GA" # end # - # @return [ Many | Enumerator ] The association or an enumerator if no - # block was provided. + # @return [ EmbedsMany::Proxy | Enumerator ] The proxy or an + # enumerator if no block was provided. def delete_if - if block_given? - dup_target = _target.dup - dup_target.each do |doc| - delete(doc) if yield(doc) - end - self - else - super - end + return super unless block_given? + + _target.dup.each { |doc| delete(doc) if yield doc } + + self end # Destroy all the documents in the association whilst running callbacks. @@ -256,37 +333,13 @@ def find(...) criteria.find(...) end - # Instantiate a new embeds_many association. - # - # @example Create the new association. - # Many.new(person, addresses, association) - # - # @param [ Mongoid::Document ] base The document this association hangs off of. - # @param [ Array ] target The child documents of the association. - # @param [ Mongoid::Association::Relatable ] association The association metadata. - # - # @return [ Many ] The proxy. - def initialize(base, target, association) - init(base, target, association) do - _target.each_with_index do |doc, index| - integrate(doc) - doc._index = index - end - update_attributes_hash - @_unscoped = _target.dup - @_target = scope(_target) - end - end - # Get all the documents in the association that are loaded into memory. # # @example Get the in memory documents. # relation.in_memory # # @return [ Array ] The documents in memory. - def in_memory - _target - end + alias_method :in_memory, :_target # Pop documents off the association. This can be a single document or # multiples, and will automatically persist the changes. @@ -300,17 +353,12 @@ def in_memory # @param [ Integer ] count The number of documents to pop, or 1 if not # provided. # - # @return [ Mongoid::Document | Array ] The popped document(s). + # @return [ Mongoid::Document | Array | nil ] The popped document(s). def pop(count = nil) - if count - if (docs = _target[_target.size - count, _target.size]) - docs.each { |doc| delete(doc) } - end - else - delete(_target[-1]) - end.tap do - update_attributes_hash - end + return [] if count&.zero? + + docs = _target.last(count || 1).each { |doc| delete(doc) } + count.nil? || docs.empty? ? docs.first : docs end # Shift documents off the association. This can be a single document or @@ -325,17 +373,12 @@ def pop(count = nil) # @param [ Integer ] count The number of documents to shift, or 1 if not # provided. # - # @return [ Mongoid::Document | Array ] The shifted document(s). + # @return [ Mongoid::Document | Array | nil ] The shifted document(s). def shift(count = nil) - if count - if !_target.empty? && (docs = _target[0, count]) - docs.each { |doc| delete(doc) } - end - else - delete(_target[0]) - end.tap do - update_attributes_hash - end + return [] if count&.zero? + + docs = _target.first(count || 1).each { |doc| delete(doc) } + count.nil? || docs.empty? ? docs.first : docs end # Substitutes the supplied target documents for the existing documents @@ -369,8 +412,15 @@ def unscoped private + # The internal unscoped documents. + # + # @param [ Array ] docs The documents. + # + # @return [ Array ] The unscoped docs. + attr_accessor :_unscoped + def object_already_related?(document) - _target.any? { |existing| existing._id && existing === document } + _target.any? { |existing| existing._id && existing == document } end # Appends the document to the target array, updating the index on the @@ -408,21 +458,6 @@ def criteria _association.criteria(_base, _target) end - # Deletes one document from the target and unscoped. - # - # @api private - # - # @example Delete one document. - # relation.delete_one(doc) - # - # @param [ Mongoid::Document ] document The document to delete. - def delete_one(document) - _target.delete_one(document) - _unscoped.delete_one(document) - update_attributes_hash - reindex - end - # Integrate the document into the association. will set its metadata and # attempt to bind the inverse. # @@ -521,26 +556,6 @@ def remove_all(conditions = {}, method = :delete) removed end - # Get the internal unscoped documents. - # - # @example Get the unscoped documents. - # relation._unscoped - # - # @return [ Array ] The unscoped documents. - def _unscoped - @_unscoped ||= [] - end - - # Set the internal unscoped documents. - # - # @example Set the unscoped documents. - # relation._unscoped = docs - # - # @param [ Array ] docs The documents. - # - # @return [ Array ] The unscoped docs. - attr_writer :_unscoped - # Returns a list of attributes hashes for each document. # # @return [ Array ] The list of attributes hashes @@ -558,42 +573,6 @@ def update_attributes_hash _base.attributes.merge!(_association.store_as => _target.map(&:attributes)) end end - - class << self - # Returns the eager loader for this association. - # - # @param [ Array ] associations The - # associations to be eager loaded - # @param [ Array ] docs The parent documents - # that possess the given associations, which ought to be - # populated by the eager-loaded documents. - # - # @return [ Mongoid::Association::Embedded::Eager ] - def eager_loader(associations, docs) - Eager.new(associations, docs) - end - - # Returns true if the association is an embedded one. In this case - # always true. - # - # @example Is the association embedded? - # Association::Embedded::EmbedsMany.embedded? - # - # @return [ true ] true. - def embedded? - true - end - - # Returns the suffix of the foreign key field, either "_id" or "_ids". - # - # @example Get the suffix for the foreign key. - # Association::Embedded::EmbedsMany.foreign_key_suffix - # - # @return [ nil ] nil. - def foreign_key_suffix - nil - end - end end end end diff --git a/lib/mongoid/association/embedded/embeds_one/proxy.rb b/lib/mongoid/association/embedded/embeds_one/proxy.rb index eccbb6ded..5dbe9ccb0 100644 --- a/lib/mongoid/association/embedded/embeds_one/proxy.rb +++ b/lib/mongoid/association/embedded/embeds_one/proxy.rb @@ -33,7 +33,7 @@ class Proxy < Association::One # @param [ Mongoid::Document ] target The child document in the association. # @param [ Mongoid::Association::Relatable ] association The association metadata. def initialize(base, target, association) - init(base, target, association) do + super do characterize_one(_target) bind_one characterize_one(_target) @@ -53,58 +53,20 @@ def initialize(base, target, association) # # @return [ Mongoid::Document | nil ] The association or nil. def substitute(replacement) - if replacement != self - if _assigning? - _base.add_atomic_unset(_target) unless replacement - else - # The associated object will be replaced by the below update if non-nil, so only - # run the callbacks and state-changing code by passing persist: false in that case. - _target.destroy(persist: !replacement) if persistable? - - # A little explanation on why this is needed... Say we have three assignments: - # - # canvas.palette = palette - # canvas.palette = nil - # canvas.palette = palette - # Where canvas embeds_one palette. - # - # Previously, what was happening was, on the first assignment, - # palette was considered a "new record" (new_record?=true) and - # thus palette was being inserted into the database. However, - # on the third assignment, we're trying to reassign the palette, - # palette is no longer considered a new record, because it had - # been inserted previously. This is not exactly accurate, - # because the second assignment ultimately removed the palette - # from the database, so it needs to be reinserted. Since the - # palette's new_record is false, Mongoid ends up "updating" the - # document, which doesn't reinsert it into the database. - # - # The change I introduce here, respecifies palette as a "new - # record" when it gets removed from the database, so if it is - # reassigned, it will be reinserted into the database. - _target.new_record = true - end - unbind_one - unless replacement - update_attributes_hash(replacement) - - # when `touch: true` is the default (see MONGOID-5016), creating - # an embedded document will touch the parent, and will cause the - # _descendants list to be initialized and memoized. If the object - # is then deleted, we need to make sure and un-memoize that list, - # otherwise when the update happens, the memoized _descendants list - # gets used and the "deleted" subdocument gets added again. - _reset_memoized_descendants! - - return nil - end - replacement = Factory.build(klass, replacement) if replacement.is_a?(::Hash) - self._target = replacement - characterize_one(_target) - bind_one - update_attributes_hash(_target) - _target.save if persistable? + return self if replacement == self + + if _assigning? + _base.add_atomic_unset(_target) unless replacement + else + update_target_when_not_assigning(replacement) end + + unbind_one + + return nil if replace_with_nil_document(replacement) + + replace_with(replacement) + self end @@ -144,6 +106,79 @@ def update_attributes_hash(replacement) end end + # When not ``_assigning?``, the target may need to be destroyed, and + # may need to be marked as a new record. This method takes care of + # that, with extensive documentation for why it is necessary. + # + # @param [ Mongoid::Document ] replacement The replacement document. + def update_target_when_not_assigning(replacement) + # The associated object will be replaced by the below update if non-nil, so only + # run the callbacks and state-changing code by passing persist: false in that case. + _target.destroy(persist: !replacement) if persistable? + + # A little explanation on why this is needed... Say we have three assignments: + # + # canvas.palette = palette + # canvas.palette = nil + # canvas.palette = palette + # Where canvas embeds_one palette. + # + # Previously, what was happening was, on the first assignment, + # palette was considered a "new record" (new_record?=true) and + # thus palette was being inserted into the database. However, + # on the third assignment, we're trying to reassign the palette, + # palette is no longer considered a new record, because it had + # been inserted previously. This is not exactly accurate, + # because the second assignment ultimately removed the palette + # from the database, so it needs to be reinserted. Since the + # palette's new_record is false, Mongoid ends up "updating" the + # document, which doesn't reinsert it into the database. + # + # The change I introduce here, respecifies palette as a "new + # record" when it gets removed from the database, so if it is + # reassigned, it will be reinserted into the database. + _target.new_record = true + end + + # Checks the argument. If it is non-nil, this method does nothing. + # Otherwise, it performs the logic for replacing the current + # document with nil. + # + # @param [ Mongoid::Document | Hash | nil ] replacement The document + # to check. + # + # @return [ true | false ] Whether a nil document was substituted + # or not. + def replace_with_nil_document(replacement) + return false if replacement + + update_attributes_hash(replacement) + + # when `touch: true` is the default (see MONGOID-5016), creating + # an embedded document will touch the parent, and will cause the + # _descendants list to be initialized and memoized. If the object + # is then deleted, we need to make sure and un-memoize that list, + # otherwise when the update happens, the memoized _descendants list + # gets used and the "deleted" subdocument gets added again. + _reset_memoized_descendants! + + true + end + + # Replaces the current document with the given replacement + # document, which may be a Hash of attributes. + # + # @param [ Mongoid::Document | Hash ] replacement The document to + # substitute in place of the current document. + def replace_with(replacement) + replacement = Factory.build(klass, replacement) if replacement.is_a?(::Hash) + self._target = replacement + characterize_one(_target) + bind_one + update_attributes_hash(_target) + _target.save if persistable? + end + class << self # Returns the eager loader for this association. # diff --git a/lib/mongoid/association/macros.rb b/lib/mongoid/association/macros.rb index e35a9d2d2..627cb39be 100644 --- a/lib/mongoid/association/macros.rb +++ b/lib/mongoid/association/macros.rb @@ -35,10 +35,15 @@ module Macros # @api private class_attribute :aliased_associations + # @return [ Set ] The set of associations that are configured + # with :store_as parameter. + class_attribute :stored_as_associations + self.embedded = false self.embedded_relations = BSON::Document.new self.relations = BSON::Document.new self.aliased_associations = {} + self.stored_as_associations = Set.new end # This is convenience for libraries still on the old API. @@ -51,6 +56,7 @@ def associations relations end + # Class methods for associations. module ClassMethods # Adds the association back to the parent document. This macro is @@ -219,6 +225,7 @@ def define_association!(macro_name, name, options = {}, &block) self.relations = relations.merge(name => assoc) if assoc.embedded? && assoc.respond_to?(:store_as) && assoc.store_as != name aliased_associations[assoc.store_as] = name + stored_as_associations << assoc.store_as end end end diff --git a/lib/mongoid/association/proxy.rb b/lib/mongoid/association/proxy.rb index 2650eb7c2..4c15658fc 100644 --- a/lib/mongoid/association/proxy.rb +++ b/lib/mongoid/association/proxy.rb @@ -10,13 +10,28 @@ module Association class Proxy extend Forwardable - UNFORWARDABLE_METHODS = /\A(?:__.*|send|object_id|equal\?|respond_to\?|respond_to_missing\?|tap|public_send|extend_proxy|extend_proxies)\z/.freeze + # Specific methods to prevent from being undefined. + # + # @api private + KEEP_METHODS = %i[ + send + object_id + equal? + respond_to? + respond_to_missing? + tap + public_send + extend_proxy + extend_proxies + ].freeze alias_method :extend_proxy, :extend # We undefine most methods to get them sent through to the target. instance_methods.each do |method| - undef_method(method) unless UNFORWARDABLE_METHODS.match?(method) + next if method.to_s.start_with?('__') || KEEP_METHODS.include?(method) + + undef_method(method) end include Threaded::Lifecycle @@ -42,16 +57,12 @@ class Proxy def_delegators :binding, :bind_one, :unbind_one def_delegator :_base, :collection_name - # Convenience for setting the target and the association metadata properties since - # all proxies will need to do this. - # - # @example Initialize the proxy. - # proxy.init(person, name, association) + # Sets the target and the association metadata properties. # # @param [ Mongoid::Document ] base The base document on the proxy. # @param [ Mongoid::Document | Array ] target The target of the proxy. # @param [ Mongoid::Association::Relatable ] association The association metadata. - def init(base, target, association) + def initialize(base, target, association) @_base = base @_target = target @_association = association diff --git a/lib/mongoid/association/referenced/belongs_to/proxy.rb b/lib/mongoid/association/referenced/belongs_to/proxy.rb index aae93ee97..0735e6641 100644 --- a/lib/mongoid/association/referenced/belongs_to/proxy.rb +++ b/lib/mongoid/association/referenced/belongs_to/proxy.rb @@ -25,7 +25,7 @@ class Proxy < Association::One # association. # @param [ Mongoid::Association::Relatable ] association The association object. def initialize(base, target, association) - init(base, target, association) do + super do characterize_one(_target) bind_one end diff --git a/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb b/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb index fb384dd53..04c454688 100644 --- a/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb +++ b/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb @@ -14,6 +14,33 @@ class HasAndBelongsToMany # which must be loaded. class Proxy < Referenced::HasMany::Proxy + # Class-level methods for HasAndBelongsToMany::Proxy + module ClassMethods + + # Get the eager loader object for this type of association. + # + # @example Get the eager loader object + # + # @param [ Mongoid::Association::Relatable ] association The association metadata. + # @param [ Array ] docs The array of documents. + def eager_loader(association, docs) + Eager.new(association, docs) + end + + # Returns true if the association is an embedded one. In this case + # always false. + # + # @example Is this association embedded? + # Referenced::ManyToMany.embedded? + # + # @return [ false ] Always false. + def embedded? + false + end + end + + extend ClassMethods + # Appends a document or array of documents to the association. Will set # the parent and update the index in the process. # @@ -80,21 +107,7 @@ def concat(documents) docs = [] inserts = [] - documents.each do |doc| - next unless doc - - append(doc) - if persistable? || _creating? - ids[doc.public_send(_association.primary_key)] = true - save_or_delay(doc, docs, inserts) - else - existing = _base.public_send(foreign_key) - unless existing.include?(doc.public_send(_association.primary_key)) - existing.push(doc.public_send(_association.primary_key)) and unsynced(_base, foreign_key) - end - end - end - + documents.each { |doc| append_document(doc, ids, docs, inserts) } _base.push(foreign_key => ids.keys) if persistable? || _creating? persist_delayed(docs, inserts) self @@ -143,6 +156,10 @@ def delete(document) doc end + # Mongoid::Extensions::Array defines Array#delete_one, so we need + # to make sure that method behaves reasonably on proxies, too. + alias_method :delete_one, :delete + # Removes all associations between the base document and the target # documents by deleting the foreign keys and the references, orphaning # the target documents in the process. @@ -152,43 +169,10 @@ def delete(document) # # @param [ Array ] replacement The replacement documents. def nullify(replacement = []) - _target.each do |doc| - execute_callback :before_remove, doc - end - - unless _association.forced_nil_inverse? - ipk = if (field = _association.options[:inverse_primary_key]) - _base.public_send(field) - else - _base._id - end - if replacement - objects_to_clear = _base.public_send(foreign_key) - replacement.collect do |object| - object.public_send(_association.primary_key) - end - criteria(objects_to_clear).pull(inverse_foreign_key => ipk) - else - criteria.pull(inverse_foreign_key => ipk) - end - end - + _target.each { |doc| execute_callback :before_remove, doc } + cleanup_inverse_for(replacement) unless _association.forced_nil_inverse? _base.set(foreign_key => _base.public_send(foreign_key).clear) if persistable? - - after_remove_error = nil - - many_to_many = _target.clear do |doc| - unbind_one(doc) - doc.changed_attributes.delete(inverse_foreign_key) unless _association.forced_nil_inverse? - begin - execute_callback :after_remove, doc - rescue StandardError => e - after_remove_error = e - end - end - - raise after_remove_error if after_remove_error - - many_to_many + clear_target_for_nullify end alias_method :nullify_all, :nullify @@ -200,7 +184,7 @@ def nullify(replacement = []) # deletion. # # @example Replace the association. - # person.preferences.substitute([ new_post ]) + # person.preferences.substitute([ new_post ]) # # @param [ Array ] replacement The replacement target. # @@ -245,14 +229,11 @@ def clear_foreign_key_changes # # @api private def reset_foreign_key_changes - if _base.changed_attributes.key?(foreign_key) - fk = _base.changed_attributes[foreign_key].dup - yield if block_given? - _base.changed_attributes[foreign_key] = fk - else - yield if block_given? - clear_foreign_key_changes - end + prior_fk_change = _base.changed_attributes.key?(foreign_key) + fk = _base.changed_attributes[foreign_key].dup + yield if block_given? + _base.changed_attributes[foreign_key] = fk + clear_foreign_key_changes unless prior_fk_change end # Appends the document to the target array, updating the index on the @@ -317,36 +298,86 @@ def criteria(id_list = nil) # @param [ Mongoid::Document ] doc The document to flag. # @param [ Symbol ] key The key to flag on the document. # - # @return [ true ] true. + # @return [ true ] The value true. def unsynced(doc, key) doc._synced[key] = false true end - class << self + # Does the cleanup for the inverse of the association when + # replacing the relation with another list of documents. + # + # @param [ Array | nil ] replacement The list of documents + # that will replace the current list. + def cleanup_inverse_for(replacement) + if replacement + new_ids = replacement.collect { |doc| doc.public_send(_association.primary_key) } + objects_to_clear = _base.public_send(foreign_key) - new_ids + criteria(objects_to_clear).pull(inverse_foreign_key => inverse_primary_key) + else + criteria.pull(inverse_foreign_key => inverse_primary_key) + end + end - # Get the Eager object for this type of association. - # - # @example Get the eager loader object - # - # @param [ Mongoid::Association::Relatable ] association The association metadata. - # @param [ Array ] docs The array of documents. - # - # @return [ Mongoid::Association::Referenced::HasAndBelongsToMany::Eager ] - # The eager loader. - def eager_loader(association, docs) - Eager.new(association, docs) + # The inverse primary key + # + # @return [ Object ] the inverse primary key + def inverse_primary_key + if (field = _association.options[:inverse_primary_key]) + _base.public_send(field) + else + _base._id end + end - # Returns true if the association is an embedded one. In this case - # always false. - # - # @example Is this association embedded? - # Referenced::ManyToMany.embedded? - # - # @return [ false ] Always false. - def embedded? - false + # Clears the _target list and executes callbacks for each document. + # If an exception occurs in an after_remove hook, the exception is + # saved, the processing completes, and *then* the exception is + # re-raised. + # + # @return [ Array ] The replacement documents. + def clear_target_for_nullify + after_remove_error = nil + many_to_many = _target.clear do |doc| + unbind_one(doc) + doc.changed_attributes.delete(inverse_foreign_key) unless _association.forced_nil_inverse? + + begin + execute_callback :after_remove, doc + rescue StandardError => e + after_remove_error = e + end + end + + raise after_remove_error if after_remove_error + + many_to_many + end + + # Processes a single document as part of a ``concat`` command. + # + # @param [ Mongoid::Document ] doc The document to append. + # @param [ Hash ] ids The mapping of primary keys that have been + # visited. + # @param [ Array ] docs The list of new docs to be inserted later, + # in bulk. + # @param [ Array ] inserts The list of Hashes of attributes that will + # be inserted, corresponding to the ``docs`` list. + def append_document(doc, ids, docs, inserts) + return unless doc + + append(doc) + + pk = doc.public_send(_association.primary_key) + if persistable? || _creating? + ids[pk] = true + save_or_delay(doc, docs, inserts) + else + existing = _base.public_send(foreign_key) + return if existing.include?(pk) + + existing.push(pk) + unsynced(_base, foreign_key) end end end diff --git a/lib/mongoid/association/referenced/has_many/proxy.rb b/lib/mongoid/association/referenced/has_many/proxy.rb index 81fb68f2e..b9a75fe34 100644 --- a/lib/mongoid/association/referenced/has_many/proxy.rb +++ b/lib/mongoid/association/referenced/has_many/proxy.rb @@ -14,9 +14,55 @@ class HasMany class Proxy < Association::Many extend Forwardable + # Class-level methods for HasMany::Proxy + module ClassMethods + + # Get the eager loader object for this type of association. + # + # @example Get the eager loader object + # + # @param [ Association ] association The association object. + # @param [ Array ] docs The array of documents. + # + # @return [ Mongoid::Association::Referenced::HasMany::Eager ] + # The eager loader. + def eager_loader(association, docs) + Eager.new(association, docs) + end + + # Returns true if the association is an embedded one. In this case + # always false. + # + # @example Is this association embedded? + # Referenced::Many.embedded? + # + # @return [ false ] Always false. + def embedded? + false + end + end + + extend ClassMethods + def_delegator :criteria, :count def_delegators :_target, :first, :in_memory, :last, :reset, :uniq + # Instantiate a new references_many association. Will set the foreign key + # and the base on the inverse object. + # + # @example Create the new association. + # Referenced::Many.new(base, target, association) + # + # @param [ Mongoid::Document ] base The document this association hangs off of. + # @param [ Array ] target The target of the association. + # @param [ Mongoid::Association::Relatable ] association The association metadata. + def initialize(base, target, association) + enum = HasMany::Enumerable.new(target, base, association) + super(base, enum, association) do + raise_mixed if klass.embedded? && !klass.cyclic? + end + end + # Appends a document or array of documents to the association. Will set # the parent and update the index in the process. # @@ -63,6 +109,7 @@ def concat(documents) append(doc) save_or_delay(doc, docs, inserts) if persistable? end + persist_delayed(docs, inserts) self end @@ -101,17 +148,22 @@ def build(attributes = {}, type = nil) # @return [ Mongoid::Document ] The matching document. def delete(document) execute_callbacks_around(:remove, document) do - _target.delete(document) do |doc| + result = _target.delete(document) do |doc| if doc unbind_one(doc) cascade!(doc) unless _assigning? end - end.tap do - reset_unloaded end + + reset_unloaded + result end end + # Mongoid::Extensions::Array defines Array#delete_one, so we need + # to make sure that method behaves reasonably on proxies, too. + alias_method :delete_one, :delete + # Deletes all related documents from the database given the supplied # conditions. # @@ -220,22 +272,6 @@ def find(*args, &block) matching end - # Instantiate a new references_many association. Will set the foreign key - # and the base on the inverse object. - # - # @example Create the new association. - # Referenced::Many.new(base, target, association) - # - # @param [ Mongoid::Document ] base The document this association hangs off of. - # @param [ Array ] target The target of the association. - # @param [ Mongoid::Association::Relatable ] association The association metadata. - def initialize(base, target, association) - enum = HasMany::Enumerable.new(target, base, association) - init(base, enum, association) do - raise_mixed if klass.embedded? && !klass.cyclic? - end - end - # Removes all associations between the base document and the target # documents by deleting the foreign keys and the references, orphaning # the target documents in the process. @@ -260,25 +296,24 @@ def nullify # # @return [ Many ] The association emptied. def purge - if _association.destructive? - after_remove_error = nil - criteria.delete_all - many = _target.clear do |doc| - execute_callback :before_remove, doc - unbind_one(doc) - doc.destroyed = true - begin - execute_callback :after_remove, doc - rescue StandardError => e - after_remove_error = e - end - end - raise after_remove_error if after_remove_error + return nullify unless _association.destructive? - many - else - nullify + after_remove_error = nil + criteria.delete_all + many = _target.clear do |doc| + execute_callback :before_remove, doc + unbind_one(doc) + doc.destroyed = true + begin + execute_callback :after_remove, doc + rescue StandardError => e + after_remove_error = e + end end + + raise after_remove_error if after_remove_error + + many end alias_method :clear, :purge @@ -514,11 +549,9 @@ def remove_all(conditions = nil, method = :delete_all) # @param [ Array ] ids The ids. def remove_not_in(ids) removed = criteria.not_in(_id: ids) - if _association.destructive? - removed.delete_all - else - removed.update_all(foreign_key => nil) - end + + update_or_delete_all(removed) + in_memory.each do |doc| next if ids.include?(doc._id) @@ -528,6 +561,19 @@ def remove_not_in(ids) end end + # If the association is destructive, the matching documents will + # be removed. Otherwise, their foreign keys will be set to nil. + # + # @param [ Mongoid::Criteria ] removed The criteria for the documents to + # remove. + def update_or_delete_all(removed) + if _association.destructive? + removed.delete_all + else + removed.update_all(foreign_key => nil) + end + end + # Save a persisted document immediately or delay a new document for # batch insert. # @@ -547,33 +593,6 @@ def save_or_delay(doc, docs, inserts) doc.save end end - - class << self - - # Get the Eager object for this type of association. - # - # @example Get the eager loader object - # - # @param [ Association ] association The association object. - # @param [ Array ] docs The array of documents. - # - # @return [ Mongoid::Association::Referenced::HasMany::Eager ] - # The eager loader. - def eager_loader(association, docs) - Eager.new(association, docs) - end - - # Returns true if the association is an embedded one. In this case - # always false. - # - # @example Is this association embedded? - # Referenced::Many.embedded? - # - # @return [ false ] Always false. - def embedded? - false - end - end end end end diff --git a/lib/mongoid/association/referenced/has_one/proxy.rb b/lib/mongoid/association/referenced/has_one/proxy.rb index 851efd367..676594e36 100644 --- a/lib/mongoid/association/referenced/has_one/proxy.rb +++ b/lib/mongoid/association/referenced/has_one/proxy.rb @@ -13,6 +13,36 @@ class HasOne # document on the opposite-side collection which must be loaded. class Proxy < Association::One + # Class-level methods for HasOne::Proxy + module ClassMethods + + # Get the eager loader object for this type of association. + # + # @example Get the eager loader object + # + # @param [ Association ] association The association object. + # @param [ Array ] docs The array of documents. + # + # @return [ Mongoid::Association::Referenced::HasOne::Eager ] + # The eager loader. + def eager_loader(association, docs) + Eager.new(association, docs) + end + + # Returns true if the association is an embedded one. In this case + # always false. + # + # @example Is this association embedded? + # Referenced::One.embedded? + # + # @return [ false ] Always false. + def embedded? + false + end + end + + extend ClassMethods + # Instantiate a new references_one association. Will set the foreign key # and the base on the inverse object. # @@ -23,7 +53,7 @@ class Proxy < Association::One # @param [ Mongoid::Document ] target The target (child) of the association. # @param [ Mongoid::Association::Relatable ] association The association metadata. def initialize(base, target, association) - init(base, target, association) do + super do raise_mixed if klass.embedded? && !klass.cyclic? characterize_one(_target) bind_one @@ -53,16 +83,7 @@ def nullify # # @return [ One ] The association. def substitute(replacement) - if self != replacement - unbind_one - if persistable? - if _association.destructive? - send(_association.dependent) - elsif persisted? - save - end - end - end + prepare_for_replacement if self != replacement HasOne::Proxy.new(_base, replacement, _association) if replacement end @@ -88,30 +109,17 @@ def persistable? _base.persisted? && !_binding? && !_building? end - class << self + # Takes the necessary steps to prepare for the current document + # to be replaced by a non-nil substitute. + def prepare_for_replacement + unbind_one - # Get the Eager object for this type of association. - # - # @example Get the eager loader object - # - # @param [ Association ] association The association object. - # @param [ Array ] docs The array of documents. - # - # @return [ Mongoid::Association::Referenced::HasOne::Eager ] - # The eager loader. - def eager_loader(association, docs) - Eager.new(association, docs) - end + return unless persistable? - # Returns true if the association is an embedded one. In this case - # always false. - # - # @example Is this association embedded? - # Referenced::One.embedded? - # - # @return [ false ] Always false. - def embedded? - false + if _association.destructive? + send(_association.dependent) + elsif persisted? + save end end end diff --git a/lib/mongoid/atomic.rb b/lib/mongoid/atomic.rb index 5c80c08df..c0cdcfefd 100644 --- a/lib/mongoid/atomic.rb +++ b/lib/mongoid/atomic.rb @@ -315,6 +315,13 @@ def process_flagged_destroys private + # Clears all pending atomic updates. + def reset_atomic_updates! + Atomic::UPDATES.each do |update| + send(update).clear + end + end + # Generates the atomic updates in the correct order. # # @example Generate the updates. diff --git a/lib/mongoid/attributes/processing.rb b/lib/mongoid/attributes/processing.rb index 4a6913e8e..f936a17d3 100644 --- a/lib/mongoid/attributes/processing.rb +++ b/lib/mongoid/attributes/processing.rb @@ -52,18 +52,44 @@ def pending_attribute?(key, value) end if relations.key?(aliased) - pending_relations[name] = value + set_pending_relation(name, aliased, value) return true end if nested_attributes.key?(aliased) - pending_nested[name] = value + set_pending_nested(name, aliased, value) return true end false end + # Set value of the pending relation. + # + # @param [ Symbol ] name The name of the relation. + # @param [ Symbol ] aliased The aliased name of the relation. + # @param [ Object ] value The value of the relation. + def set_pending_relation(name, aliased, value) + if stored_as_associations.include?(name) + pending_relations[aliased] = value + else + pending_relations[name] = value + end + end + + # Set value of the pending nested attribute. + # + # @param [ Symbol ] name The name of the nested attribute. + # @param [ Symbol ] aliased The aliased name of the nested attribute. + # @param [ Object ] value The value of the nested attribute. + def set_pending_nested(name, aliased, value) + if stored_as_associations.include?(name) + pending_nested[aliased] = value + else + pending_nested[name] = value + end + end + # Get all the pending associations that need to be set. # # @example Get the pending associations. diff --git a/lib/mongoid/changeable.rb b/lib/mongoid/changeable.rb index 0303c3590..bf5bfff1e 100644 --- a/lib/mongoid/changeable.rb +++ b/lib/mongoid/changeable.rb @@ -52,12 +52,10 @@ def changed_attributes # # @return [ Hash ] The changes. def changes - changes = {} - changed.each do |attr| + changed.each_with_object({}) do |attr, changes| change = attribute_change(attr) changes[attr] = change if change - end - changes.with_indifferent_access + end.with_indifferent_access end # Call this method after save, so the changes can be properly switched. @@ -72,9 +70,7 @@ def move_changes @previous_changes = changes @attributes_before_last_save = @previous_attributes @previous_attributes = attributes.dup - Atomic::UPDATES.each do |update| - send(update).clear - end + reset_atomic_updates! changed_attributes.clear end @@ -165,25 +161,18 @@ def saved_change_to_attribute(attr) # in an attribute during the save that triggered the callbacks to run. # # @param [ String ] attr The name of the attribute. - # @param **kwargs The optional keyword arguments. - # - # @option **kwargs [ Object ] :from The object the attribute was changed from. - # @option **kwargs [ Object ] :to The object the attribute was changed to. + # @param [ Object ] from The object the attribute was changed from (optional). + # @param [ Object ] to The object the attribute was changed to (optional). # # @return [ true | false ] Whether the attribute has changed during the last save. - def saved_change_to_attribute?(attr, **kwargs) + def saved_change_to_attribute?(attr, from: Utils::PLACEHOLDER, to: Utils::PLACEHOLDER) changes = saved_change_to_attribute(attr) return false unless changes.is_a?(Array) + return true if Utils.placeholder?(from) && Utils.placeholder?(to) + return changes.first == from if Utils.placeholder?(to) + return changes.last == to if Utils.placeholder?(from) - if kwargs.key?(:from) && kwargs.key?(:to) - changes.first == kwargs[:from] && changes.last == kwargs[:to] - elsif kwargs.key?(:from) - changes.first == kwargs[:from] - elsif kwargs.key?(:to) - changes.last == kwargs[:to] - else - true - end + changes.first == from && changes.last == to end # Returns whether this attribute change the next time we save. @@ -232,27 +221,45 @@ def attribute_change(attr) [changed_attributes[attr], attributes[attr]] if attribute_changed?(attr) end + # A class for representing the default value that an attribute was changed + # from or to. + # + # @api private + class Anything + # `Anything` objects are always equal to everything. This simplifies + # the logic for asking whether an attribute has changed or not. If the + # `from` or `to` value is a `Anything` (because it was not + # explicitly given), any comparison with it will suggest the value has + # not changed. + # + # @param [ Object ] _other The object being compared with this object. + # + # @return [ true ] Always returns true. + def ==(_other) + true + end + end + + # A singleton object to represent an optional `to` or `from` value + # that was not explicitly provided to #attribute_changed? + ATTRIBUTE_UNCHANGED = Anything.new + # Determine if a specific attribute has changed. # # @example Has the attribute changed? # model.attribute_changed?("name") # # @param [ String ] attr The name of the attribute. - # @param **kwargs The optional keyword arguments. - # - # @option **kwargs [ Object ] :from The object the attribute was changed from. - # @option **kwargs [ Object ] :to The object the attribute was changed to. + # @param [ Object ] from The object the attribute was changed from (optional). + # @param [ Object ] to The object the attribute was changed to (optional). # # @return [ true | false ] Whether the attribute has changed. - def attribute_changed?(attr, **kwargs) + def attribute_changed?(attr, from: ATTRIBUTE_UNCHANGED, to: ATTRIBUTE_UNCHANGED) attr = database_field_name(attr) - - return false if !changed_attributes.key?(attr) || - (changed_attributes[attr] == attributes[attr]) || - (kwargs.key?(:from) && (changed_attributes[attr] != kwargs[:from])) || - (kwargs.key?(:to) && (attributes[attr] != kwargs[:to])) - - true + changed_attributes.key?(attr) && + changed_attributes[attr] != attributes[attr] && + from == changed_attributes[attr] && + to == attributes[attr] end # Get whether or not the field has a different value from the default. @@ -264,8 +271,7 @@ def attribute_changed?(attr, **kwargs) # # @return [ true | false ] If the attribute differs. def attribute_changed_from_default?(attr) - field = fields[attr] - return false unless field + return false unless (field = fields[attr]) attributes[attr] != field.eval_default(self) end @@ -340,6 +346,7 @@ def reset_attributes_before_type_cast @attributes_before_type_cast = @attributes.dup end + # Class-level methods for Changeable. module ClassMethods private diff --git a/lib/mongoid/clients.rb b/lib/mongoid/clients.rb index 0629e7cee..ca6866ab3 100644 --- a/lib/mongoid/clients.rb +++ b/lib/mongoid/clients.rb @@ -66,6 +66,9 @@ def with_name(name) return clients[name_as_symbol] if clients[name_as_symbol] CREATE_LOCK.synchronize do + if (key_vault_client = Mongoid.clients.dig(name_as_symbol, :options, :auto_encryption_options, :key_vault_client)) + clients[key_vault_client.to_sym] ||= Clients::Factory.create(key_vault_client) + end clients[name_as_symbol] ||= Clients::Factory.create(name) end end diff --git a/lib/mongoid/clients/factory.rb b/lib/mongoid/clients/factory.rb index 336e2ca82..db2179eba 100644 --- a/lib/mongoid/clients/factory.rb +++ b/lib/mongoid/clients/factory.rb @@ -60,7 +60,7 @@ def create_client(configuration) config = configuration.dup uri = config.delete(:uri) - database = config.delete(:database) + database = config.delete(:database) || Mongo::URI.get(uri).database hosts = config.delete(:hosts) opts = config.delete(:options) || {} @@ -103,7 +103,7 @@ def build_auto_encryption_options(opts, database) auto_encryption_options[:schema_map] = Mongoid.config.encryption_schema_map(database) end if auto_encryption_options.key?(:key_vault_client) - auto_encryption_options[:key_vault_client] = Mongoid::Clients.with_name( + auto_encryption_options[:key_vault_client] = Mongoid.client( auto_encryption_options[:key_vault_client] ) end diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index faefc33a6..275465e26 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -75,6 +75,19 @@ module Config # Store BigDecimals as Decimal128s instead of strings in the db. option :map_big_decimal_to_decimal128, default: true + # Allow BSON::Decimal128 to be parsed and returned directly in + # field values. When BSON 5 is present and the this option is set to false + # (the default), BSON::Decimal128 values in the database will be returned + # as BigDecimal. + # + # @note this option only has effect when BSON 5+ is present. Otherwise, + # the setting is ignored. + option :allow_bson5_decimal128, default: false, on_change: lambda { |allow| + if BSON::VERSION >= '5.0.0' + BSON::Registry.register(BSON::Decimal128::BSON_TYPE, allow ? BSON::Decimal128 : BigDecimal) + end + } + # Sets the async_query_executor for the application. By default the thread pool executor # is set to `:immediate. Options are: # @@ -105,6 +118,16 @@ module Config # document might be ignored, or it might work, depending on the situation. option :immutable_ids, default: true + # When this flag is true, callbacks for every embedded document will be + # called only once, even if the embedded document is embedded in multiple + # documents in the root document's dependencies graph. + # This is the default in 9.0. Setting this flag to false restores the + # pre-9.0 behavior, where callbacks are called for every occurrence of an + # embedded document. The pre-9.0 behavior leads to a problem that for multi + # level nested documents callbacks are called multiple times. + # See https://jira.mongodb.org/browse/MONGOID-5542 + option :prevent_multiple_calls_of_embedded_callbacks, default: true + # Returns the Config singleton, for use in the configure DSL. # # @return [ self ] The Config singleton. diff --git a/lib/mongoid/config/defaults.rb b/lib/mongoid/config/defaults.rb index f2059f153..ef624adfd 100644 --- a/lib/mongoid/config/defaults.rb +++ b/lib/mongoid/config/defaults.rb @@ -10,7 +10,7 @@ module Defaults # Note that this method will load the *new* functionality introduced in # the given Mongoid version. # - # @param [ String | Float ] The version number as X.y. + # @param [ String | Float ] version The version number as X.y. # # raises [ ArgumentError ] if an invalid version is given. def load_defaults(version) diff --git a/lib/mongoid/config/encryption.rb b/lib/mongoid/config/encryption.rb index edb58ee10..c6e1f5621 100644 --- a/lib/mongoid/config/encryption.rb +++ b/lib/mongoid/config/encryption.rb @@ -14,12 +14,12 @@ module Encryption # Generate the encryption schema map for the provided models. # - # @param [ String ] database The database name. + # @param [ String ] default_database The default database name. # @param [ Array ] models The models to generate the schema map for. # Defaults to all models in the application. # # @return [ Hash ] The encryption schema map. - def encryption_schema_map(database, models = ::Mongoid.models) + def encryption_schema_map(default_database, models = ::Mongoid.models) visited = Set.new models.each_with_object({}) do |model, map| next if visited.include?(model) @@ -28,6 +28,7 @@ def encryption_schema_map(database, models = ::Mongoid.models) next if model.embedded? next unless model.encrypted? + database = model.storage_options.fetch(:database) { default_database } key = "#{database}.#{model.collection_name}" props = metadata_for(model).merge(properties_for(model, visited)) map[key] = props unless props.empty? @@ -77,7 +78,7 @@ def encryption_schema_map(database, models = ::Mongoid.models) # @return [ Hash ] The encryptMetadata object. def metadata_for(model) result = {}.tap do |metadata| - if (key_id = key_id_for(model.encrypt_metadata[:key_id])) + if (key_id = key_id_for(model.encrypt_metadata[:key_id], model.encrypt_metadata[:key_name_field])) metadata['keyId'] = key_id end if model.encrypt_metadata.key?(:deterministic) @@ -88,6 +89,7 @@ def metadata_for(model) end end end + if result.empty? {} else @@ -133,7 +135,7 @@ def properties_for_fields(model) if (algorithm = algorithm_for(field)) props[name]['encrypt']['algorithm'] = algorithm end - if (key_id = key_id_for(field.key_id)) + if (key_id = key_id_for(field.key_id, field.key_name_field)) props[name]['encrypt']['keyId'] = key_id end end @@ -151,12 +153,10 @@ def properties_for_fields(model) def properties_for_relations(model, visited) model.relations.each_with_object({}) do |(name, relation), props| next if visited.include?(relation.relation_class) - - visited << relation.relation_class - next unless relation.is_a?(Association::Embedded::EmbedsMany) || - relation.is_a?(Association::Embedded::EmbedsOne) + next unless relation.is_a?(Association::Embedded::EmbedsOne) next unless relation.relation_class.encrypted? + visited << relation.relation_class metadata_for( relation.relation_class ).merge( @@ -195,13 +195,22 @@ def algorithm_for(field) # key id. # # @param [ String | nil ] key_id_base64 The base64 encoded key id. - # - # @return [ Array | nil ] The keyId encryption schema field, - # or nil if the key id is nil. - def key_id_for(key_id_base64) - return nil if key_id_base64.nil? + # @param [ String | nil ] key_name_field The name of the key name field. + # + # @return [ Array | String | nil ] The keyId encryption schema field, + # JSON pointer to the field that contains keyAltName, + # or nil if both key_id_base64 and key_name_field are nil. + def key_id_for(key_id_base64, key_name_field) + return nil if key_id_base64.nil? && key_name_field.nil? + if !key_id_base64.nil? && !key_name_field.nil? + raise ArgumentError.new('Specifying both key_id and key_name_field is not allowed') + end - [BSON::Binary.new(Base64.decode64(key_id_base64), :uuid)] + if key_id_base64.nil? + "/#{key_name_field}" + else + [BSON::Binary.new(Base64.decode64(key_id_base64), :uuid)] + end end end end diff --git a/lib/mongoid/config/introspection.rb b/lib/mongoid/config/introspection.rb index c22086567..5ed748597 100644 --- a/lib/mongoid/config/introspection.rb +++ b/lib/mongoid/config/introspection.rb @@ -123,7 +123,8 @@ def unindent(text) ((?:^\s*\#.*\n)+) # match one or more lines of comments ^\s+option\s+ # followed immediately by a line declaring an option :(\w+),\s+ # match the option's name, followed by a comma - default:\s+(.*) # match the default value for the option + default:\s+(.*?) # match the default value for the option + (?:,.*?)? # skip any other configuration \n) # end with a newline /x.freeze diff --git a/lib/mongoid/config/options.rb b/lib/mongoid/config/options.rb index bb7f280a7..f8cd84e04 100644 --- a/lib/mongoid/config/options.rb +++ b/lib/mongoid/config/options.rb @@ -25,6 +25,8 @@ def defaults # @param [ Hash ] options Extras for the option. # # @option options [ Object ] :default The default value. + # @option options [ Proc | nil ] :on_change The callback to invoke when the + # setter is invoked. def option(name, options = {}) defaults[name] = settings[name] = options[:default] @@ -38,6 +40,7 @@ def option(name, options = {}) define_method("#{name}=") do |value| settings[name] = value + options[:on_change]&.call(value) end define_method("#{name}?") do diff --git a/lib/mongoid/contextual/memory.rb b/lib/mongoid/contextual/memory.rb index 854098304..ad7fa549b 100644 --- a/lib/mongoid/contextual/memory.rb +++ b/lib/mongoid/contextual/memory.rb @@ -156,7 +156,7 @@ def first(limit = nil) # # @return [ Mongoid::Document ] The first document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def first! first || raise_document_not_found_error @@ -218,7 +218,7 @@ def last(limit = nil) # # @return [ Mongoid::Document ] The last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def last! last || raise_document_not_found_error @@ -342,7 +342,7 @@ def take(limit = nil) # # @return [ Mongoid::Document ] The document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def take! take || raise_document_not_found_error @@ -403,8 +403,6 @@ def update_all(attributes = nil) # @example Get the second document. # context.second # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The second document. def second eager_load([documents.second]).first @@ -416,9 +414,7 @@ def second # @example Get the second document. # context.second! # - # @return [ Mongoid::Document ] The second document. - # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def second! second || raise_document_not_found_error @@ -429,8 +425,6 @@ def second! # @example Get the third document. # context.third # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The third document. def third eager_load([documents.third]).first @@ -442,9 +436,7 @@ def third # @example Get the third document. # context.third! # - # @return [ Mongoid::Document ] The third document. - # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def third! third || raise_document_not_found_error @@ -455,8 +447,6 @@ def third! # @example Get the fourth document. # context.fourth # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The fourth document. def fourth eager_load([documents.fourth]).first @@ -468,9 +458,7 @@ def fourth # @example Get the fourth document. # context.fourth! # - # @return [ Mongoid::Document ] The fourth document. - # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def fourth! fourth || raise_document_not_found_error @@ -481,8 +469,6 @@ def fourth! # @example Get the fifth document. # context.fifth # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The fifth document. def fifth eager_load([documents.fifth]).first @@ -496,7 +482,7 @@ def fifth # # @return [ Mongoid::Document ] The fifth document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def fifth! fifth || raise_document_not_found_error @@ -507,8 +493,6 @@ def fifth! # @example Get the second to last document. # context.second_to_last # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The second to last document. def second_to_last eager_load([documents.second_to_last]).first @@ -522,7 +506,7 @@ def second_to_last # # @return [ Mongoid::Document ] The second to last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def second_to_last! second_to_last || raise_document_not_found_error @@ -533,8 +517,6 @@ def second_to_last! # @example Get the third to last document. # context.third_to_last # - # @param [ Integer ] limit The number of documents to return. - # # @return [ Mongoid::Document ] The third to last document. def third_to_last eager_load([documents.third_to_last]).first @@ -548,7 +530,7 @@ def third_to_last # # @return [ Mongoid::Document ] The third to last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def third_to_last! third_to_last || raise_document_not_found_error diff --git a/lib/mongoid/contextual/mongo.rb b/lib/mongoid/contextual/mongo.rb index c7782452c..7333f059a 100644 --- a/lib/mongoid/contextual/mongo.rb +++ b/lib/mongoid/contextual/mongo.rb @@ -382,7 +382,7 @@ def take(limit = nil) # # @return [ Mongoid::Document ] The document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents to take. def take! # Do to_a first so that the Mongo#first method is not used and the @@ -567,7 +567,7 @@ def first(limit = nil) # # @return [ Mongoid::Document ] The first document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def first! first || raise_document_not_found_error @@ -609,7 +609,7 @@ def last(limit = nil) # # @return [ Mongoid::Document ] The last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def last! last || raise_document_not_found_error @@ -633,7 +633,7 @@ def second # # @return [ Mongoid::Document ] The second document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def second! second || raise_document_not_found_error @@ -657,7 +657,7 @@ def third # # @return [ Mongoid::Document ] The third document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def third! third || raise_document_not_found_error @@ -681,7 +681,7 @@ def fourth # # @return [ Mongoid::Document ] The fourth document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def fourth! fourth || raise_document_not_found_error @@ -705,7 +705,7 @@ def fifth # # @return [ Mongoid::Document ] The fifth document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def fifth! fifth || raise_document_not_found_error @@ -731,7 +731,7 @@ def second_to_last # # @return [ Mongoid::Document ] The second to last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def second_to_last! second_to_last || raise_document_not_found_error @@ -757,7 +757,7 @@ def third_to_last # # @return [ Mongoid::Document ] The third to last document. # - # @raises [ Mongoid::Errors::DocumentNotFound ] raises when there are no + # @raise [ Mongoid::Errors::DocumentNotFound ] raises when there are no # documents available. def third_to_last! third_to_last || raise_document_not_found_error diff --git a/lib/mongoid/contextual/mongo/documents_loader.rb b/lib/mongoid/contextual/mongo/documents_loader.rb index 41bcbc12d..3f71dc234 100644 --- a/lib/mongoid/contextual/mongo/documents_loader.rb +++ b/lib/mongoid/contextual/mongo/documents_loader.rb @@ -83,9 +83,9 @@ def self.executor(name = Mongoid.async_query_executor) # @param [ Class ] klass Mongoid model class to instantiate documents. # All records obtained from the database will be converted to an # instance of this class, if possible. - # @param [ Mongoid::Criteria ] criteria. Criteria that specifies which + # @param [ Mongoid::Criteria ] criteria Criteria that specifies which # documents should be loaded. - # @param [ Concurrent::AbstractExecutorService ] executor. Executor that + # @param [ Concurrent::AbstractExecutorService ] executor Executor that # is capable of running `Concurrent::Promises::Future` instances. def initialize(view, klass, criteria, executor: self.class.executor) @view = view diff --git a/lib/mongoid/contextual/none.rb b/lib/mongoid/contextual/none.rb index 6660f4c4a..ef842875f 100644 --- a/lib/mongoid/contextual/none.rb +++ b/lib/mongoid/contextual/none.rb @@ -159,7 +159,7 @@ def first(limit = nil) # @example Get the first document in null context. # context.first! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def first! raise_document_not_found_error end @@ -181,7 +181,7 @@ def last(limit = nil) # @example Get the last document in null context. # context.last! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def last! raise_document_not_found_error end @@ -203,7 +203,7 @@ def take(limit = nil) # @example Take a document in null context. # context.take! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def take! raise_document_not_found_error end @@ -223,7 +223,7 @@ def second # @example Get the second document in null context. # context.second! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def second! raise_document_not_found_error end @@ -243,7 +243,7 @@ def third # @example Get the third document in null context. # context.third! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def third! raise_document_not_found_error end @@ -263,7 +263,7 @@ def fourth # @example Get the fourth document in null context. # context.fourth! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def fourth! raise_document_not_found_error end @@ -283,7 +283,7 @@ def fifth # @example Get the fifth document in null context. # context.fifth! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def fifth! raise_document_not_found_error end @@ -303,7 +303,7 @@ def second_to_last # @example Get the second to last document in null context. # context.second_to_last! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def second_to_last! raise_document_not_found_error end @@ -323,7 +323,7 @@ def third_to_last # @example Get the third to last document in null context. # context.third_to_last! # - # @raises [ Mongoid::Errors::DocumentNotFound ] always raises. + # @raise [ Mongoid::Errors::DocumentNotFound ] always raises. def third_to_last! raise_document_not_found_error end diff --git a/lib/mongoid/criteria/queryable/selector.rb b/lib/mongoid/criteria/queryable/selector.rb index 0c7903c4b..c965be9c0 100644 --- a/lib/mongoid/criteria/queryable/selector.rb +++ b/lib/mongoid/criteria/queryable/selector.rb @@ -19,8 +19,8 @@ class Selector < Smash def merge!(other) other.each_pair do |key, value| if value.is_a?(Hash) && self[key.to_s].is_a?(Hash) - value = self[key.to_s].merge(value) do |k, old_val, new_val| - case k + value = self[key.to_s].merge(value) do |inner_key, old_val, new_val| + case inner_key.to_s when '$in' new_val & old_val when '$nin' diff --git a/lib/mongoid/criteria/queryable/storable.rb b/lib/mongoid/criteria/queryable/storable.rb index 9b3bd8fd3..abdcc157c 100644 --- a/lib/mongoid/criteria/queryable/storable.rb +++ b/lib/mongoid/criteria/queryable/storable.rb @@ -47,7 +47,7 @@ def add_field_expression(field, value) if value.is_a?(Hash) && selector[field].is_a?(Hash) && value.keys.all? do |key| key_s = key.to_s - key_s.start_with?('$') && !selector[field].key?(key_s) + key_s.start_with?('$') && selector[field].keys.map(&:to_s).exclude?(key_s) end # Multiple operators can be combined on the same field by # adding them to the existing hash. @@ -81,7 +81,7 @@ def add_field_expression(field, value) # # {'$or' => [{'hello' => 'world'}]} # - # ... and operator is '$or' and op_expr is `[{'test' => 123'}]`, + # ... and operator is '$or' and op_expr is +[{'test' => 123'}]+, # the resulting selector will be: # # {'$or' => [{'hello' => 'world'}, {'test' => 123}]} @@ -155,7 +155,7 @@ def add_logical_operator_expression(operator, op_expr) # # {'foo' => 'bar', '$or' => [{'hello' => 'world'}]} # - # ... and operator is '$or' and op_expr is `{'test' => 123'}`, + # ... and operator is '$or' and op_expr is +{'test' => 123'}+, # the resulting selector will be: # # {'foo' => 'bar', '$or' => [{'hello' => 'world'}, {'test' => 123}]} diff --git a/lib/mongoid/deprecation.rb b/lib/mongoid/deprecation.rb index d7d75c549..6c8963a18 100644 --- a/lib/mongoid/deprecation.rb +++ b/lib/mongoid/deprecation.rb @@ -13,7 +13,7 @@ class Deprecation < ::ActiveSupport::Deprecation # Overrides default ActiveSupport::Deprecation behavior # to use Mongoid's logger. # - # @return Array The deprecation behavior. + # @return [ Array ] The deprecation behavior. def behavior @behavior ||= Array(lambda do |message, callstack, _deprecation_horizon, _gem_name| logger = Mongoid.logger diff --git a/lib/mongoid/document.rb b/lib/mongoid/document.rb index 9ebec281e..327d8d245 100644 --- a/lib/mongoid/document.rb +++ b/lib/mongoid/document.rb @@ -95,13 +95,13 @@ def identity # otherwise it will be set to a fresh +BSON::ObjectId+ string. # # @example Create a new document. - # Person.new(:title => "Sir") + # Person.new(:title => 'Sir') # # @param [ Hash ] attrs The attributes to set up the document with. # # @return [ Mongoid::Document ] A new document. def initialize(attrs = nil, &block) - construct_document(attrs, execute_callbacks: true, &block) + construct_document(attrs, &block) end # Return the model name of the document. @@ -148,68 +148,96 @@ def as_document # # @return [ Mongoid::Document ] An instance of the specified class. def becomes(klass) - unless klass.include?(Mongoid::Document) - raise ArgumentError.new('A class which includes Mongoid::Document is expected') - end + mongoid_document_check!(klass) became = klass.new(clone_document) - became._id = _id - became.instance_variable_set(:@changed_attributes, changed_attributes) - new_errors = ActiveModel::Errors.new(became) - new_errors.copy!(errors) - became.instance_variable_set(:@errors, new_errors) - became.instance_variable_set(:@new_record, new_record?) - became.instance_variable_set(:@destroyed, destroyed?) - became.changed_attributes[klass.discriminator_key] = self.class.discriminator_value - became[klass.discriminator_key] = klass.discriminator_value - - # mark embedded docs as persisted - embedded_relations.each_pair do |name, _meta| - without_autobuild do - relation = became.__send__(name) - Array.wrap(relation).each do |r| - r.instance_variable_set(:@new_record, new_record?) - end - end - end + became.internal_state = internal_state became end + # Sets the internal state of this document. Used only by #becomes to + # help initialize a retyped document. + # + # @param [ Hash ] state The map of internal state values. + # + # @api private + def internal_state=(state) + self._id = state[:id] + @changed_attributes = state[:changed_attributes] + @errors = ActiveModel::Errors.new(self).tap { |e| e.copy!(state[:errors]) } + @new_record = state[:new_record] + @destroyed = state[:destroyed] + + update_discriminator(state[:discriminator_key_was]) + + mark_persisted_state_for_embedded_documents(state[:new_record]) + end + + # Handles the setup and execution of callbacks, if callbacks are to + # be executed; otherwise, adds the appropriate callbacks to the pending + # callbacks list. + # + # @param execute_callbacks [ true | false ] Whether callbacks should be + # executed or not. + # + # @api private + def _handle_callbacks_after_instantiation(execute_callbacks) + if execute_callbacks + apply_defaults + yield self if block_given? + run_callbacks(:find) unless _find_callbacks.empty? + run_callbacks(:initialize) unless _initialize_callbacks.empty? + else + yield self if block_given? + self.pending_callbacks += %i[apply_defaults find initialize] + end + end + private # Does the construction of a document. # # @param [ Hash ] attrs The attributes to set up the document with. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks - # should be run. + # @param [ Hash ] options The options to use. + # + # @option options [ true | false ] :execute_callbacks Flag specifies + # whether callbacks should be run. # # @return [ Mongoid::Document ] A new document. # + # @note A Ruby 2.x bug prevents the options hash from being keyword + # arguments. Once we drop support for Ruby 2.x, we can reimplement + # the options hash as keyword arguments. + # See https://bugs.ruby-lang.org/issues/15753 + # # @api private - def construct_document(attrs = nil, execute_callbacks: true) - @__parent = nil + def construct_document(attrs = nil, options = {}) + execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?) + + self._parent = nil _building do - @new_record = true - @attributes ||= {} - apply_pre_processed_defaults - apply_default_scoping + prepare_to_process_attributes + process_attributes(attrs) do yield(self) if block_given? end @attributes_before_type_cast = @attributes.merge(attributes_before_type_cast) - if execute_callbacks - apply_post_processed_defaults - run_callbacks(:initialize) unless _initialize_callbacks.empty? - else - pending_callbacks << :apply_post_processed_defaults - pending_callbacks << :initialize - end + resolve_post_construction_callbacks(execute_callbacks) end self end + # Initializes the object state prior to attribute processing; this is + # called only from #construct_document. + def prepare_to_process_attributes + @new_record = true + @attributes ||= {} + apply_pre_processed_defaults + apply_default_scoping + end + # Returns the logger # # @return [ Logger ] The configured logger or a default Logger instance. @@ -224,7 +252,7 @@ def logger # # @return [ String ] The model key. def model_key - @model_cache_key ||= self.class.model_name.cache_key + @model_key ||= self.class.model_name.cache_key end # Returns a hash of the attributes. @@ -240,37 +268,123 @@ def as_attributes embedded_relations.each_pair do |name, meta| without_autobuild do - relation = send(name) - stored = meta.store_as - if attributes.key?(stored) || relation.present? - if relation.nil? - attributes.delete(stored) - else - attributes[stored] = relation.send(:as_attributes) - end - end + add_attributes_for_relation(name, meta) end end + attributes end + # Adds the attributes for the given relation to the document's attributes. + # + # @param name [ String | Symbol ] the name of the relation to add + # @param meta [ Mongoid::Assocation::Relatable ] the relation object + def add_attributes_for_relation(name, meta) + relation = send(name) + stored = meta.store_as + return unless attributes.key?(stored) || relation.present? + + if relation.nil? + attributes.delete(stored) + else + attributes[stored] = relation.send(:as_attributes) + end + end + + # Checks that the given argument is an instance of `Mongoid::Document`. + # + # @param klass [ Class ] The class to test. + # + # @raise [ ArgumentError ] if the class does not include + # Mongoid::Document. + def mongoid_document_check!(klass) + return if klass.include?(Mongoid::Document) + + raise ArgumentError.new('A class which includes Mongoid::Document is expected') + end + + # Constructs a hash representing the internal state of this object, + # suitable for passing to #internal_state=. + # + # @return [ Hash ] the map of internal state values + def internal_state + { + id: _id, + changed_attributes: changed_attributes, + errors: errors, + new_record: new_record?, + destroyed: destroyed?, + discriminator_key_was: self.class.discriminator_value + } + end + + # Updates the value of the discriminator_key for this object, setting its + # previous value to `key_was`. + # + # @param key_was [ String ] the previous value of the discriminator key. + def update_discriminator(key_was) + changed_attributes[self.class.discriminator_key] = key_was + self[self.class.discriminator_key] = self.class.discriminator_value + end + + # Marks all embedded documents with the given "new_record" state. + # + # @param [ true | false ] new_record whether or not the embedded records + # should be flagged as new records or not. + def mark_persisted_state_for_embedded_documents(new_record) + embedded_relations.each_pair do |name, _meta| + without_autobuild do + relation = __send__(name) + Array.wrap(relation).each do |r| + r.instance_variable_set(:@new_record, new_record) + end + end + end + end + + # Either executes or enqueues the post-construction callbacks. + # + # @param [ true | false ] execute_callbacks whether the callbacks + # should be executed (true) or enqueued (false) + def resolve_post_construction_callbacks(execute_callbacks) + if execute_callbacks + apply_post_processed_defaults + run_callbacks(:initialize) unless _initialize_callbacks.empty? + else + pending_callbacks << :apply_post_processed_defaults + pending_callbacks << :initialize + end + end + + # Class-level methods for Document objects. module ClassMethods + # Indicate whether callbacks should be invoked by default or not, + # within the block. Callbacks may always be explicitly invoked by passing + # `execute_callbacks: true` where available. + # + # @param [ true | false ] execute_callbacks Whether callbacks should be + # suppressed or not. + def with_callbacks(execute_callbacks) + saved = Threaded.execute_callbacks? + Threaded.execute_callbacks = execute_callbacks + yield + ensure + Threaded.execute_callbacks = saved + end # Instantiate a new object, only when loaded from the database or when # the attributes have already been typecast. # # @example Create the document. - # Person.instantiate(:title => "Sir", :age => 30) + # Person.instantiate(:title => 'Sir', :age => 30) # # @param [ Hash ] attrs The hash of attributes to instantiate with. # @param [ Integer ] selected_fields The selected fields from the # criteria. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks - # should be run. # # @return [ Mongoid::Document ] A new document. def instantiate(attrs = nil, selected_fields = nil, &block) - instantiate_document(attrs, selected_fields, execute_callbacks: true, &block) + instantiate_document(attrs, selected_fields, &block) end # Instantiate the document. @@ -278,13 +392,23 @@ def instantiate(attrs = nil, selected_fields = nil, &block) # @param [ Hash ] attrs The hash of attributes to instantiate with. # @param [ Integer ] selected_fields The selected fields from the # criteria. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks - # should be run. + # @param [ Hash ] options The options to use. + # + # @option options [ true | false ] :execute_callbacks Flag specifies + # whether callbacks should be run. + # + # @yield [ Mongoid::Document ] If a block is given, yields the newly + # instantiated document to it. # # @return [ Mongoid::Document ] A new document. # + # @note A Ruby 2.x bug prevents the options hash from being keyword + # arguments. Once we drop support for Ruby 2.x, we can reimplement + # the options hash as keyword arguments. + # # @api private - def instantiate_document(attrs = nil, selected_fields = nil, execute_callbacks: true) + def instantiate_document(attrs = nil, selected_fields = nil, options = {}, &block) + execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?) attributes = attrs&.to_h || {} doc = allocate @@ -292,15 +416,7 @@ def instantiate_document(attrs = nil, selected_fields = nil, execute_callbacks: doc.instance_variable_set(:@attributes, attributes) doc.instance_variable_set(:@attributes_before_type_cast, attributes.dup) - if execute_callbacks - doc.apply_defaults - yield(doc) if block_given? - doc.run_callbacks(:find) unless doc._find_callbacks.empty? - doc.run_callbacks(:initialize) unless doc._initialize_callbacks.empty? - else - yield(doc) if block_given? - doc.pending_callbacks += %i[apply_defaults find initialize] - end + doc._handle_callbacks_after_instantiation(execute_callbacks, &block) doc end @@ -308,15 +424,22 @@ def instantiate_document(attrs = nil, selected_fields = nil, execute_callbacks: # Allocates and constructs a document. # # @param [ Hash ] attrs The attributes to set up the document with. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks - # should be run. + # @param [ Hash ] options The options to use. + # + # @option options [ true | false ] :execute_callbacks Flag specifies + # whether callbacks should be run. + # + # @note A Ruby 2.x bug prevents the options hash from being keyword + # arguments. Once we drop support for Ruby 2.x, we can reimplement + # the options hash as keyword arguments. + # See https://bugs.ruby-lang.org/issues/15753 # # @return [ Mongoid::Document ] A new document. # # @api private - def construct_document(attrs = nil, execute_callbacks: true) - doc = allocate - doc.send(:construct_document, attrs, execute_callbacks: execute_callbacks) + def construct_document(attrs = nil, options = {}) + execute_callbacks = options.fetch(:execute_callbacks, Threaded.execute_callbacks?) + with_callbacks(execute_callbacks) { new(attrs) } end # Returns all types to query for when using this class as the base. @@ -326,7 +449,7 @@ def construct_document(attrs = nil, execute_callbacks: true) # # @return [ Array ] All subclasses of the current document. def _types - @_type ||= (descendants + [self]).uniq.map(&:discriminator_value) + @_types ||= (descendants + [self]).uniq.map(&:discriminator_value) end # Clear the @_type cache. This is generally called when changing the discriminator @@ -337,7 +460,7 @@ def _types # # @api private def _mongoid_clear_types - @_type = nil + @_types = nil superclass._mongoid_clear_types if hereditary? end diff --git a/lib/mongoid/encryptable.rb b/lib/mongoid/encryptable.rb index f3f5ee40e..cc571abf7 100644 --- a/lib/mongoid/encryptable.rb +++ b/lib/mongoid/encryptable.rb @@ -19,7 +19,10 @@ module ClassMethods # # @param [ Hash ] options The encryption metadata. # @option options [ String ] :key_id The base64-encoded UUID of the key - # used to encrypt fields. + # used to encrypt fields. Mutually exclusive with :key_name_field option. + # @option options [ String ] :key_name_field The name of the field that + # contains the key alt name to use for encryption. Mutually exclusive + # with :key_id option. # @option options [ true | false ] :deterministic Whether the encryption # is deterministic or not. def encrypt_with(options = {}) diff --git a/lib/mongoid/extensions/hash.rb b/lib/mongoid/extensions/hash.rb index c546d80f8..ea0862381 100644 --- a/lib/mongoid/extensions/hash.rb +++ b/lib/mongoid/extensions/hash.rb @@ -37,11 +37,11 @@ def __mongoize_object_id__ # # @return [ Hash ] A new consolidated hash. def __consolidate__(klass) - consolidated = {} - each_pair do |key, value| - if /\$/.match?(key) - value.each_pair do |k, v| - value[k] = key == '$rename' ? v.to_s : mongoize_for(key, klass, k, v) + each_pair.with_object({}) do |(key, value), consolidated| + if key.start_with?('$') + value = value.each_with_object({}) do |(key2, value2), hash| + key2 = klass.database_field_name(key2) + hash[key2] = key == '$rename' ? value2.to_s : mongoize_for(key, klass, key2, value2) end consolidated[key] ||= {} consolidated[key].update(value) @@ -50,7 +50,6 @@ def __consolidate__(klass) consolidated['$set'].update(key => mongoize_for(key, klass, key, value)) end end - consolidated end # Checks whether conditions given in this hash are known to be diff --git a/lib/mongoid/factory.rb b/lib/mongoid/factory.rb index 6520dbfd0..ecc258d57 100644 --- a/lib/mongoid/factory.rb +++ b/lib/mongoid/factory.rb @@ -6,6 +6,133 @@ module Mongoid module Factory extend self + # A helper class for instantiating a model using either it's type + # class directly, or via a type class specified via a discriminator + # key. + # + # @api private + class Instantiator + # @return [ Mongoid::Document ] The primary model class being referenced + attr_reader :klass + + # @return [ Hash | nil ] The Hash of attributes to use when + # instantiating the model. + attr_reader :attributes + + # @return [ Mongoid::Criteria | nil ] The criteria object to + # use as a secondary source for the selected fields; also used when + # setting the inverse association. + attr_reader :criteria + + # @return [ Array | nil ] The list of field names that should + # be explicitly (and exclusively) included in the new record. + attr_reader :selected_fields + + # @return [ String | nil ] The identifier of the class that + # should be loaded and instantiated, in the case of a polymorphic + # class specification. + attr_reader :type + + # Creates a new Factory::Initiator. + # + # @param klass [ Mongoid::Document ] The primary class to reference when + # instantiating the model. + # @param attributes [ Hash | nil ] (Optional) The hash of attributes to + # use when instantiating the model. + # @param criteria [ Mongoid::Criteria | nil ] (Optional) The criteria + # object to use as a secondary source for the selected fields; also + # used when setting the inverse association. + # @param selected_fields [ Array | nil ] The list of field names that + # should be explicitly (and exclusively) included in the new record. + def initialize(klass, attributes, criteria, selected_fields) + @klass = klass + @attributes = attributes + @criteria = criteria + @selected_fields = selected_fields || + (criteria && criteria.options[:fields]) + @type = attributes && attributes[klass.discriminator_key] + end + + # Builds and returns a new instance of the requested class. + # + # @param execute_callbacks [ true | false ] Whether or not the Document + # callbacks should be invoked with the new instance. + # + # @raise [ Errors::UnknownModel ] when the requested type does not exist, + # or if it does not respond to the `instantiate` method. + # + # @return [ Mongoid::Document ] The new document instance. + def instance(execute_callbacks: Threaded.execute_callbacks?) + if type.blank? + instantiate_without_type(execute_callbacks) + else + instantiate_with_type(execute_callbacks) + end + end + + private + + # Instantiate the given class without any given subclass. + # + # @param [ true | false ] execute_callbacks Whether this method should + # invoke document callbacks. + # + # @return [ Document ] The instantiated document. + def instantiate_without_type(execute_callbacks) + klass.instantiate_document(attributes, selected_fields, execute_callbacks: execute_callbacks).tap do |obj| + if criteria&.association && criteria&.parent_document + obj.set_relation(criteria.association.inverse, criteria.parent_document) + end + end + end + + # Instantiate the given `type`, which must map to another Mongoid::Document + # model. + # + # @param [ true | false ] execute_callbacks Whether this method should + # invoke document callbacks. + # + # @return [ Document ] The instantiated document. + def instantiate_with_type(execute_callbacks) + constantized_type.instantiate_document( + attributes, selected_fields, + execute_callbacks: execute_callbacks + ) + end + + # Retreive the `Class` instance of the requested type, either by finding it + # in the `klass` discriminator mapping, or by otherwise finding a + # Document model with the given name. + # + # @return [ Mongoid::Document ] the requested Document model + def constantized_type + @constantized_type ||= begin + constantized = klass.get_discriminator_mapping(type) || constantize(type) + + # Check if the class is a Document class + raise Errors::UnknownModel.new(camelized, type) unless constantized.respond_to?(:instantiate) + + constantized + end + end + + # Attempts to convert the argument into a Class object by camelizing + # it and treating the result as the name of a constant. + # + # @param type [ String ] The name of the type to constantize + # + # @raise [ Errors::UnknownModel ] if the argument does not correspond to + # an existing constant. + # + # @return [ Class ] the Class that the type resolves to + def constantize(type) + camelized = type.camelize + camelized.constantize + rescue NameError + raise Errors::UnknownModel.new(camelized, type) + end + end + # Builds a new +Document+ from the supplied attributes. # # This method either instantiates klass or a descendant of klass if the attributes include @@ -20,32 +147,40 @@ module Factory # # @param [ Class ] klass The class to instantiate from if _type is not present. # @param [ Hash ] attributes The document attributes. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks + # + # @option options [ true | false ] :execute_callbacks Flag specifies whether callbacks # should be run. # # @return [ Mongoid::Document ] The instantiated document. def build(klass, attributes = nil) - execute_build(klass, attributes, execute_callbacks: true) + execute_build(klass, attributes) end # Execute the build. # # @param [ Class ] klass The class to instantiate from if _type is not present. # @param [ Hash ] attributes The document attributes. - # @param [ true | false ] execute_callbacks Flag specifies whether callbacks - # should be run. + # @param [ Hash ] options The options to use. + # + # @option options [ true | false ] :execute_callbacks Flag specifies + # whether callbacks should be run. + # + # @note A Ruby 2.x bug prevents the options hash from being keyword + # arguments. Once we drop support for Ruby 2.x, we can reimplement + # the options hash as keyword arguments. + # See https://bugs.ruby-lang.org/issues/15753 # # @return [ Mongoid::Document ] The instantiated document. # # @api private - def execute_build(klass, attributes = nil, execute_callbacks: true) + def execute_build(klass, attributes = nil, options = {}) attributes ||= {} dvalue = attributes[klass.discriminator_key] || attributes[klass.discriminator_key.to_sym] type = klass.get_discriminator_mapping(dvalue) if type - type.construct_document(attributes, execute_callbacks: execute_callbacks) + type.construct_document(attributes, options) else - klass.construct_document(attributes, execute_callbacks: execute_callbacks) + klass.construct_document(attributes, options) end end @@ -76,7 +211,7 @@ def execute_build(klass, attributes = nil, execute_callbacks: true) # # @return [ Mongoid::Document ] The instantiated document. def from_db(klass, attributes = nil, criteria = nil, selected_fields = nil) - execute_from_db(klass, attributes, criteria, selected_fields, execute_callbacks: true) + execute_from_db(klass, attributes, criteria, selected_fields) end # Execute from_db. @@ -97,34 +232,11 @@ def from_db(klass, attributes = nil, criteria = nil, selected_fields = nil) # @return [ Mongoid::Document ] The instantiated document. # # @api private - def execute_from_db(klass, attributes = nil, criteria = nil, selected_fields = nil, execute_callbacks: true) - selected_fields ||= criteria.options[:fields] if criteria - type = (attributes || {})[klass.discriminator_key] - if type.blank? - obj = klass.instantiate_document(attributes, selected_fields, execute_callbacks: execute_callbacks) - if criteria&.association && criteria&.parent_document - obj.set_relation(criteria.association.inverse, criteria.parent_document) - end - obj - else - unless (constantized = klass.get_discriminator_mapping(type)) - camelized = type.camelize - - # Check if the class exists - begin - constantized = camelized.constantize - rescue NameError - raise Errors::UnknownModel.new(camelized, type) - end - end - - # Check if the class is a Document class - unless constantized.respond_to?(:instantiate) - raise Errors::UnknownModel.new(camelized, type) - end - - constantized.instantiate_document(attributes, selected_fields, execute_callbacks: execute_callbacks) - end + def execute_from_db(klass, attributes = nil, criteria = nil, + selected_fields = nil, + execute_callbacks: Threaded.execute_callbacks?) + Instantiator.new(klass, attributes, criteria, selected_fields) + .instance(execute_callbacks: execute_callbacks) end end end diff --git a/lib/mongoid/fields.rb b/lib/mongoid/fields.rb index b7e97a2ce..a1e338f94 100644 --- a/lib/mongoid/fields.rb +++ b/lib/mongoid/fields.rb @@ -807,16 +807,17 @@ def field_for(name, options) # # @return [ Class ] The type of the field. # - # @raises [ Mongoid::Errors::InvalidFieldType ] if given an invalid field + # @raise [ Mongoid::Errors::InvalidFieldType ] if given an invalid field # type. # # @api private def retrieve_and_validate_type(name, type) type_mapping = TYPE_MAPPINGS[type] result = type_mapping || unmapped_type(type) + if !result.is_a?(Class) raise Errors::InvalidFieldType.new(self, name, type) - elsif INVALID_BSON_CLASSES.include?(result) + elsif unsupported_type?(result) warn_message = "Using #{result} as the field type is not supported. " warn_message += if result == BSON::Decimal128 'In BSON <= 4, the BSON::Decimal128 type will work as expected for both storing and querying, but will return a BigDecimal on query in BSON 5+.' @@ -844,6 +845,20 @@ def unmapped_type(type) type || Object end end + + # Queries whether or not the given type is permitted as a declared field + # type. + # + # @param [ Class ] type The type to query + # + # @return [ true | false ] whether or not the type is supported + # + # @api private + def unsupported_type?(type) + return !Mongoid::Config.allow_bson5_decimal128? if type == BSON::Decimal128 + + INVALID_BSON_CLASSES.include?(type) + end end end end diff --git a/lib/mongoid/fields/encrypted.rb b/lib/mongoid/fields/encrypted.rb index 9c3d48edb..96c500be1 100644 --- a/lib/mongoid/fields/encrypted.rb +++ b/lib/mongoid/fields/encrypted.rb @@ -25,6 +25,12 @@ def key_id @encryption_options[:key_id] end + # @return [ String | nil ] The name of the field that contains the + # key alt name to use for encryption; if not specified, nil is returned. + def key_name_field + @encryption_options[:key_name_field] + end + # Override the key_id for the field. # # This method is solely for testing purposes and should not be used in diff --git a/lib/mongoid/interceptable.rb b/lib/mongoid/interceptable.rb index 39834c5f2..8c020dc0d 100644 --- a/lib/mongoid/interceptable.rb +++ b/lib/mongoid/interceptable.rb @@ -153,12 +153,13 @@ def run_callbacks(kind, with_children: true, skip_if: nil, &block) # @api private def _mongoid_run_child_callbacks(kind, children: nil, &block) child, *tail = (children || cascadable_children(kind)) + with_children = !Mongoid::Config.prevent_multiple_calls_of_embedded_callbacks if child.nil? block&.call elsif tail.empty? - child.run_callbacks(child_callback_type(kind, child), &block) + child.run_callbacks(child_callback_type(kind, child), with_children: with_children, &block) else - child.run_callbacks(child_callback_type(kind, child)) do + child.run_callbacks(child_callback_type(kind, child), with_children: with_children) do _mongoid_run_child_callbacks(kind, children: tail, &block) end end diff --git a/lib/mongoid/matcher.rb b/lib/mongoid/matcher.rb index 0d275fa8f..4ed84d357 100644 --- a/lib/mongoid/matcher.rb +++ b/lib/mongoid/matcher.rb @@ -52,7 +52,7 @@ def extract_attribute(document, key) # If a document has hash fields, as_attributes would keep those fields # as Hash instances which do not offer indifferent access. # Convert to BSON::Document to get indifferent access on hash fields. - document = BSON::Document.new(document.send(:as_attributes)) + document = document.send(:as_attributes) end current = [document] @@ -62,14 +62,22 @@ def extract_attribute(document, key) current.each do |doc| case doc when Hash - new << doc[field] if doc.key?(field) + actual_key = find_exact_key(doc, field) + unless actual_key.nil? + new << doc[actual_key] + end when Array if (index = field.to_i).to_s == field && (doc.length > index) new << doc[index] end doc.each do |subdoc| - new << subdoc[field] if subdoc.is_a?(Hash) && subdoc.key?(field) + next unless subdoc.is_a?(Hash) + + actual_key = find_exact_key(subdoc, field) + unless actual_key.nil? + new << subdoc[actual_key] + end end end end @@ -79,6 +87,20 @@ def extract_attribute(document, key) current end + + # Indifferent string or symbol key lookup, returning the exact key. + # + # @param [ Hash ] hash The input hash. + # @param [ String | Symbol ] key The key to perform indifferent lookups with. + # + # @return [ String | Symbol | nil ] The exact key (with the correct type) that exists in the hash, or nil if the key does not exist. + def find_exact_key(hash, key) + key_s = key.to_s + return key_s if hash.key?(key_s) + + key_sym = key.to_sym + hash.key?(key_sym) ? key_sym : nil + end end end diff --git a/lib/mongoid/railtie.rb b/lib/mongoid/railtie.rb index 881163ff6..77a218ca0 100644 --- a/lib/mongoid/railtie.rb +++ b/lib/mongoid/railtie.rb @@ -111,6 +111,17 @@ def handle_configuration_error(error) Mongo::Monitoring::Global.subscribe Mongo::Monitoring::COMMAND, ::Mongoid::Railties::ControllerRuntime::Collector.new end + + # Add custom serializers for BSON::ObjectId + initializer 'mongoid.active_job.custom_serializers' do + require 'mongoid/railties/bson_object_id_serializer' + + config.after_initialize do + ActiveJob::Serializers.add_serializers( + [::Mongoid::Railties::ActiveJobSerializers::BsonObjectIdSerializer] + ) + end + end end end end diff --git a/lib/mongoid/railties/bson_object_id_serializer.rb b/lib/mongoid/railties/bson_object_id_serializer.rb new file mode 100644 index 000000000..2d67a8b33 --- /dev/null +++ b/lib/mongoid/railties/bson_object_id_serializer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Mongoid + module Railties + module ActiveJobSerializers + + # This class provides serialization and deserialization of BSON::ObjectId + # for ActiveJob. + # + # It is important that this class is loaded only when Rails is available + # since it depends on Rails' ActiveJob::Serializers::ObjectSerializer. + class BsonObjectIdSerializer < ActiveJob::Serializers::ObjectSerializer + + # Returns whether the argument can be serialized by this serializer. + # + # @param [ Object ] argument The argument to check. + # + # @return [ true | false ] Whether the argument can be serialized. + def serialize?(argument) + argument.is_a?(BSON::ObjectId) + end + + # Serializes the argument to be passed to the job. + # + # @param [ BSON::ObjectId ] object The object to serialize. + def serialize(object) + object.to_s + end + + # Deserializes the argument back into a BSON::ObjectId. + # + # @param [ String ] string The string to deserialize. + # + # @return [ BSON::ObjectId ] The deserialized object. + def deserialize(string) + BSON::ObjectId.from_string(string) + end + end + end + end +end diff --git a/lib/mongoid/reloadable.rb b/lib/mongoid/reloadable.rb index 6e4c35375..47e37bec0 100644 --- a/lib/mongoid/reloadable.rb +++ b/lib/mongoid/reloadable.rb @@ -16,31 +16,54 @@ module Reloadable # # @return [ Mongoid::Document ] The document, reloaded. def reload - if @atomic_selector - # Clear atomic_selector cache for sharded clusters. MONGOID-5076 - remove_instance_variable(:@atomic_selector) - end - reloaded = _reload - if Mongoid.raise_not_found_error && reloaded.blank? - shard_keys = atomic_selector.with_indifferent_access.slice(*shard_key_fields, :_id) - raise Errors::DocumentNotFound.new(self.class, _id, shard_keys) - end - @attributes = reloaded + check_for_deleted_document!(reloaded) + + reset_object!(reloaded) + + run_callbacks(:find) unless _find_callbacks.empty? + run_callbacks(:initialize) unless _initialize_callbacks.empty? + self + end + + private + + # Resets the current object using the given attributes. + # + # @param [ Hash ] attributes The attributes to use to replace the current + # attributes hash. + def reset_object!(attributes) + reset_atomic_updates! + + @attributes = attributes @attributes_before_type_cast = @attributes.dup @changed_attributes = {} @previous_changes = {} @previous_attributes = {} @previously_new_record = false + reset_readonly apply_defaults reload_relations - run_callbacks(:find) unless _find_callbacks.empty? - run_callbacks(:initialize) unless _initialize_callbacks.empty? - self end - private + # Checks to see if the given attributes argument indicates that the object + # has been deleted. If the attributes are nil or an empty Hash, then + # we assume it has been deleted. + # + # If Mongoid.raise_not_found_error is false, this will do nothing. + # + # @param [ Hash | nil ] attributes The attributes hash retrieved from + # the database + # + # @raise [ Errors::DocumentNotFound ] If the document was deleted. + def check_for_deleted_document!(attributes) + return unless Mongoid.raise_not_found_error + return if attributes.present? + + shard_keys = atomic_selector.with_indifferent_access.slice(*shard_key_fields, :_id) + raise Errors::DocumentNotFound.new(self.class, _id, shard_keys) + end # Reload the document, determining if it's embedded or not and what # behavior to use. @@ -70,8 +93,9 @@ def reload_root_document # # @return [ Hash ] The reloaded attributes. def reload_embedded_document - selector = collection(_root).find(_root.atomic_selector, session: _session).read(mode: :primary).first - extract_embedded_attributes({}.merge(selector)) + extract_embedded_attributes( + collection(_root).find(_root.atomic_selector, session: _session).read(mode: :primary).first + ) end # Extract only the desired embedded document from the attributes. @@ -84,10 +108,8 @@ def reload_embedded_document # @return [ Hash | nil ] The document's extracted attributes or nil if the # document doesn't exist. def extract_embedded_attributes(attributes) - atomic_position.split('.').inject(attributes) do |attrs, part| - attrs = attrs[/\d/.match?(part) ? part.to_i : part] - attrs - end + segments = atomic_position.split('.').map { |part| Utils.maybe_integer(part) } + attributes.dig(*segments) end end end diff --git a/lib/mongoid/tasks/encryption.rake b/lib/mongoid/tasks/encryption.rake index d947064f1..ca60c0257 100644 --- a/lib/mongoid/tasks/encryption.rake +++ b/lib/mongoid/tasks/encryption.rake @@ -1,18 +1,42 @@ # frozen_string_literal: true +require 'optparse' + namespace :db do namespace :mongoid do namespace :encryption do desc 'Create encryption key' - task :create_data_key, %i[client provider] => [:environment] do |_t, args| + task create_data_key: [:environment] do + options = {} + + parser = OptionParser.new do |opts| + opts.on('-c', '--client CLIENT', 'Name of the client to use') do |v| + options[:client_name] = v + end + opts.on('-p', '--provider PROVIDER', 'KMS provider to use') do |v| + options[:kms_provider_name] = v + end + opts.on('-n', '--key-alt-name KEY_ALT_NAME', 'Alternate name for the key') do |v| + options[:key_alt_name] = v + end + end + parser.parse!(parser.order!(ARGV) {}) # rubocop:disable Lint/EmptyBlock + result = Mongoid::Tasks::Encryption.create_data_key( - client_name: args[:client], - kms_provider_name: args[:provider] + client_name: options[:client_name], + kms_provider_name: options[:kms_provider_name], + key_alt_name: options[:key_alt_name] ) - puts "Created data key with id: '#{result[:key_id]}' " \ - "for kms provider: '#{result[:kms_provider]}' " \ - "in key vault: '#{result[:key_vault_namespace]}'." + + output = [].tap do |lines| + lines << "Created data key with id: '#{result[:key_id]}'" + lines << "with key alt name: '#{result[:key_alt_name]}'" if result[:key_alt_name] + lines << "for kms provider: '#{result[:kms_provider]}'" + lines << "in key vault: '#{result[:key_vault_namespace]}'." + end + + puts output.join(' ') end end end diff --git a/lib/mongoid/tasks/encryption.rb b/lib/mongoid/tasks/encryption.rb index 0ae3cfc70..ec179651e 100644 --- a/lib/mongoid/tasks/encryption.rb +++ b/lib/mongoid/tasks/encryption.rb @@ -15,27 +15,37 @@ module Encryption # @param [ String | nil ] client_name The name of the client to take # auto_encryption_options from. If not provided, the default client # will be used. + # @param [ String | nil ] key_alt_name The alternate name of the key. # # @return [ Hash ] A hash containing the key id as :key_id, # kms provider name as :kms_provider, and key vault namespace as # :key_vault_namespace. - def create_data_key(kms_provider_name: nil, client_name: nil) + def create_data_key(client_name: nil, kms_provider_name: nil, key_alt_name: nil) kms_provider_name, kms_providers, key_vault_namespace = prepare_arguments( kms_provider_name, client_name ) + key_vault_client = Mongoid::Clients.default.with(database: key_vault_namespace.split('.').first) + client_encryption = Mongo::ClientEncryption.new( key_vault_client, key_vault_namespace: key_vault_namespace, kms_providers: kms_providers ) - data_key_id = client_encryption.create_data_key(kms_provider_name) + + client_encryption_opts = {}.tap do |opts| + opts[:key_alt_names] = [key_alt_name] if key_alt_name + end + + data_key_id = client_encryption.create_data_key(kms_provider_name, client_encryption_opts) + { key_id: Base64.strict_encode64(data_key_id.data), kms_provider: kms_provider_name, - key_vault_namespace: key_vault_namespace - } + key_vault_namespace: key_vault_namespace, + key_alt_name: key_alt_name + }.compact end private diff --git a/lib/mongoid/threaded.rb b/lib/mongoid/threaded.rb index 2a12417df..88c62c097 100644 --- a/lib/mongoid/threaded.rb +++ b/lib/mongoid/threaded.rb @@ -32,6 +32,10 @@ module Threaded # The key for storing documents modified inside transactions. MODIFIED_DOCUMENTS_KEY = '[mongoid]:modified-documents' + # The key storing the default value for whether or not callbacks are + # executed on documents. + EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks' + extend self # Begin entry into a named thread local stack. @@ -232,10 +236,7 @@ def current_scope=(scope) # @return [ Mongoid::Criteria ] The scope. def set_current_scope(scope, klass) if scope.nil? - if Thread.current[CURRENT_SCOPE_KEY] - Thread.current[CURRENT_SCOPE_KEY].delete(klass) - Thread.current[CURRENT_SCOPE_KEY] = nil if Thread.current[CURRENT_SCOPE_KEY].empty? - end + unset_current_scope(klass) else Thread.current[CURRENT_SCOPE_KEY] ||= {} Thread.current[CURRENT_SCOPE_KEY][klass] = scope @@ -330,7 +331,7 @@ def validations_for(klass) # @param [ Mongo::Session ] session The session to save. # @param [ Mongo::Client | nil ] client The client to cache the session for. def set_session(session, client: nil) - sessions[client.object_id] = session + sessions[client] = session end # Get the cached session for this thread for a client. @@ -342,7 +343,7 @@ def set_session(session, client: nil) # # @return [ Mongo::Session | nil ] The session cached on this thread or nil. def get_session(client: nil) - sessions[client.object_id] + sessions[client] end # Clear the cached session for this thread for a client. @@ -354,7 +355,7 @@ def get_session(client: nil) # # @return [ nil ] def clear_session(client: nil) - sessions.delete(client.object_id)&.end_session + sessions.delete(client)&.end_session end # Store a reference to the document that was modified inside a transaction @@ -382,13 +383,37 @@ def clear_modified_documents(session) modified_documents[session].clear end + # Queries whether document callbacks should be executed by default for the + # current thread. Unless otherwise indicated (by #execute_callbacks=), this + # will return true. + # + # @return [ true | false ] Whether or not document callbacks should be + # executed by default. + def execute_callbacks? + if Thread.current.key?(EXECUTE_CALLBACKS) + Thread.current[EXECUTE_CALLBACKS] + else + true + end + end + + # Indicates whether document callbacks should be invoked by default for + # the current thread. Individual documents may further override the + # callback behavior, but this will be used for the default behavior. + # + # @param flag [ true | false ] Whether or not document callbacks should be + # executed by default. + def execute_callbacks=(flag) + Thread.current[EXECUTE_CALLBACKS] = flag + end + # Returns the thread store of sessions. # # @return [ Hash ] The sessions indexed by client object ID. # # @api private def sessions - Thread.current[SESSIONS_KEY] ||= {} + Thread.current[SESSIONS_KEY] ||= {}.compare_by_identity end # Returns the thread store of modified documents. @@ -402,5 +427,18 @@ def modified_documents h[k] = Set.new end end + + private + + # Removes the given klass from the current scope, and tidies the current + # scope list. + # + # @param klass [ Class ] the class to remove from the current scope. + def unset_current_scope(klass) + return unless Thread.current[CURRENT_SCOPE_KEY] + + Thread.current[CURRENT_SCOPE_KEY].delete(klass) + Thread.current[CURRENT_SCOPE_KEY] = nil if Thread.current[CURRENT_SCOPE_KEY].empty? + end end end diff --git a/lib/mongoid/traversable.rb b/lib/mongoid/traversable.rb index 95fb227a2..99b029a3f 100644 --- a/lib/mongoid/traversable.rb +++ b/lib/mongoid/traversable.rb @@ -9,31 +9,82 @@ module Mongoid module Traversable extend ActiveSupport::Concern - # Returns the parent document. + # Class-level methods for the Traversable behavior. + module ClassMethods + + # Determines if the document is a subclass of another document. + # + # @example Check if the document is a subclass. + # Square.hereditary? + # + # @return [ true | false ] True if hereditary, false if not. + def hereditary? + !!(superclass < Mongoid::Document) + end + + # When inheriting, we want to copy the fields from the parent class and + # set the on the child to start, mimicking the behavior of the old + # class_inheritable_accessor that was deprecated in Rails edge. + # + # @example Inherit from this class. + # Person.inherited(Doctor) + # + # @param [ Class ] subclass The inheriting class. + def inherited(subclass) + super + @_type = nil + subclass.aliased_fields = aliased_fields.dup + subclass.localized_fields = localized_fields.dup + subclass.fields = fields.dup + subclass.pre_processed_defaults = pre_processed_defaults.dup + subclass.post_processed_defaults = post_processed_defaults.dup + subclass._declared_scopes = Hash.new { |_hash, key| _declared_scopes[key] } + subclass.discriminator_value = subclass.name + + # We need to do this here because the discriminator_value method is + # overridden in the subclass above. + subclass.include DiscriminatorRetrieval + + # We only need the _type field if inheritance is in play, but need to + # add to the root class as well for backwards compatibility. + return if fields.key?(discriminator_key) + + default_proc = -> { self.class.discriminator_value } + field(discriminator_key, default: default_proc, type: String) + end + end + + # `_parent` is intentionally not implemented via attr_accessor because + # of the need to use a double underscore for the instance variable. + # Associations automatically create backing variables prefixed with a + # single underscore, which would conflict with this accessor if a model + # were to declare a `parent` association. + + # Retrieves the parent document of this document. # - # @returns [ Mongoid::Document ] The parent document. + # @return [ Mongoid::Document | nil ] the parent document # # @api private def _parent - @__parent ||= nil + @__parent || nil end - # Sets the parent document. + # Sets the parent document of this document. # - # @param [ Mongoid::Document ] value The parent document to set. + # @param [ Mongoid::Document | nil ] document the document to set as + # the parent document. # # @returns [ Mongoid::Document ] The parent document. # # @api private - def _parent=(value) - @__parent = value + def _parent=(document) + @__parent = document end # Module used for prepending to the various discriminator_*= methods # # @api private module DiscriminatorAssignment - # Sets the discriminator key. # # @param [ String ] value The discriminator key to set. @@ -89,7 +140,6 @@ def discriminator_value=(value) # # @api private module DiscriminatorRetrieval - # Get the name on the reading side if the discriminator_value is nil def discriminator_value @discriminator_value || name @@ -139,11 +189,20 @@ def self.get_discriminator_mapping(value) # Get all child +Documents+ to this +Document+ # - # @return [ Array ] All child documents in the hierarchy. + # @return [ Array ] All child documents in the hierarchy. # # @api private - def _children - @__children ||= collect_children + def _children(reset: false) + # See discussion above for the `_parent` method, as to why the variable + # here needs to have two underscores. + # + # rubocop:disable Naming/MemoizedInstanceVariableName + if reset + @__children = nil + else + @__children ||= collect_children + end + # rubocop:enable Naming/MemoizedInstanceVariableName end # Get all descendant +Documents+ of this +Document+ recursively. @@ -153,44 +212,48 @@ def _children # always be preferred, since they are optimized calls... This operation # can get expensive in domains with large hierarchies. # - # @return [ Array ] All descendant documents in the hierarchy. + # @return [ Array ] All descendant documents in the hierarchy. # # @api private - def _descendants - @__descendants ||= collect_descendants + def _descendants(reset: false) + # See discussion above for the `_parent` method, as to why the variable + # here needs to have two underscores. + # + # rubocop:disable Naming/MemoizedInstanceVariableName + if reset + @__descendants = nil + else + @__descendants ||= collect_descendants + end + # rubocop:enable Naming/MemoizedInstanceVariableName end # Collect all the children of this document. # - # @return [ Array ] The children. + # @return [ Array ] The children. # # @api private def collect_children - children = [] - embedded_relations.each_pair do |name, _association| - without_autobuild do - child = send(name) - children += Array.wrap(child) if child + [].tap do |children| + embedded_relations.each_pair do |name, _association| + without_autobuild do + child = send(name) + children.concat(Array.wrap(child)) if child + end end end - children end # Collect all the descendants of this document. # - # @return [ Array ] The descendants. + # @return [ Array ] The descendants. # # @api private def collect_descendants children = [] - to_expand = [] + to_expand = _children expanded = {} - embedded_relations.each_pair do |name, _association| - without_autobuild do - child = send(name) - to_expand += Array.wrap(child) if child - end - end + until to_expand.empty? expanding = to_expand to_expand = [] @@ -205,12 +268,13 @@ def collect_descendants to_expand += child._children end end + children end # Marks all descendants as being persisted. # - # @return [ Array ] The flagged descendants. + # @return [ Array ] The flagged descendants. def flag_descendants_persisted _descendants.each do |child| child.new_record = false @@ -233,9 +297,9 @@ def hereditary? # @example Set the parent document. # document.parentize(parent) # - # @param [ Mongoid::Document ] document The parent document. + # @param [ Document ] document The parent document. # - # @return [ Mongoid::Document ] The parent document. + # @return [ Document ] The parent document. def parentize(document) self._parent = document end @@ -248,7 +312,7 @@ def parentize(document) # @example Remove the child. # document.remove_child(child) # - # @param [ Mongoid::Document ] child The child (embedded) document to remove. + # @param [ Document ] child The child (embedded) document to remove. def remove_child(child) name = child.association_name if child.embedded_one? @@ -256,14 +320,14 @@ def remove_child(child) remove_ivar(name) else relation = send(name) - relation.send(:delete_one, child) + relation._remove(child) end end # After descendants are persisted we can call this to move all their # changes and flag them as persisted in one call. # - # @return [ Array ] The descendants. + # @return [ Array ] The descendants. def reset_persisted_descendants _descendants.each do |child| child.move_changes @@ -280,8 +344,8 @@ def reset_persisted_descendants # @api private def _reset_memoized_descendants! _parent&._reset_memoized_descendants! - @__children = nil - @__descendants = nil + _children reset: true + _descendants reset: true end # Return the root document in the object graph. If the current document @@ -290,12 +354,10 @@ def _reset_memoized_descendants! # @example Get the root document in the hierarchy. # document._root # - # @return [ Mongoid::Document ] The root document in the hierarchy. + # @return [ Document ] The root document in the hierarchy. def _root object = self - while object._parent - object = object._parent - end + object = object._parent while object._parent object end @@ -308,51 +370,5 @@ def _root def _root? _parent ? false : true end - - module ClassMethods - - # Determines if the document is a subclass of another document. - # - # @example Check if the document is a subclass. - # Square.hereditary? - # - # @return [ true | false ] True if hereditary, false if not. - def hereditary? - !!(superclass < Mongoid::Document) - end - - # When inheriting, we want to copy the fields from the parent class and - # set the on the child to start, mimicking the behavior of the old - # class_inheritable_accessor that was deprecated in Rails edge. - # - # @example Inherit from this class. - # Person.inherited(Doctor) - # - # @param [ Class ] subclass The inheriting class. - def inherited(subclass) - super - @_type = nil - subclass.aliased_fields = aliased_fields.dup - subclass.localized_fields = localized_fields.dup - subclass.fields = fields.dup - subclass.pre_processed_defaults = pre_processed_defaults.dup - subclass.post_processed_defaults = post_processed_defaults.dup - subclass._declared_scopes = Hash.new { |_hash, key| _declared_scopes[key] } - subclass.discriminator_value = subclass.name - - # We need to do this here because the discriminator_value method is - # overridden in the subclass above. - class << subclass - include DiscriminatorRetrieval - end - - # We only need the _type field if inheritance is in play, but need to - # add to the root class as well for backwards compatibility. - return if fields.key?(discriminator_key) - - default_proc = -> { self.class.discriminator_value } - field(discriminator_key, default: default_proc, type: String) - end - end end end diff --git a/lib/mongoid/utils.rb b/lib/mongoid/utils.rb index 4130d8113..958473f35 100644 --- a/lib/mongoid/utils.rb +++ b/lib/mongoid/utils.rb @@ -9,6 +9,35 @@ module Utils extend self + # A unique placeholder value that will never accidentally collide with + # valid values. This is useful as a default keyword argument value when + # you want the argument to be optional, but you also want to be able to + # recognize that the caller did not provide a value for it. + PLACEHOLDER = Object.new.freeze + + # Asks if the given value is a placeholder or not. + # + # @param [ Object ] value the value to compare + # + # @return [ true | false ] if the value is a placeholder or not. + def placeholder?(value) + value == PLACEHOLDER + end + + # If value can be coerced to an integer, return it as an integer. + # Otherwise, return the value itself. + # + # @param [ String ] value the string to possibly coerce. + # + # @return [ String | Integer ] the result of the coercion. + def maybe_integer(value) + if value.match?(/^\d/) + value.to_i + else + value + end + end + # This function should be used if you need to measure time. # @example Calculate elapsed time. # starting = Utils.monotonic_time diff --git a/spec/integration/active_job_spec.rb b/spec/integration/active_job_spec.rb new file mode 100644 index 000000000..18ca23262 --- /dev/null +++ b/spec/integration/active_job_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'active_job' +require 'mongoid/railties/bson_object_id_serializer' + +describe 'ActiveJob Serialization' do + skip unless defined?(ActiveJob) + + unless defined?(ApplicationJob) + class ApplicationJob < ActiveJob::Base + end + end + + class TestBsonObjectIdSerializerJob < ApplicationJob + def perform(*args) + args + end + end + + let(:band) do + Band.create! + end + + before do + ActiveJob::Serializers.add_serializers( + [Mongoid::Railties::ActiveJobSerializers::BsonObjectIdSerializer] + ) + end + + it 'serializes and deserializes BSON::ObjectId' do + expect do + TestBsonObjectIdSerializerJob.perform_later(band.id) + end.to_not raise_error + end +end diff --git a/spec/integration/callbacks_models.rb b/spec/integration/callbacks_models.rb index bc55de2ab..e20d194d4 100644 --- a/spec/integration/callbacks_models.rb +++ b/spec/integration/callbacks_models.rb @@ -155,3 +155,40 @@ class Building has_and_belongs_to_many :architects, dependent: :nullify end + +class Root + include Mongoid::Document + embeds_many :embedded_once, cascade_callbacks: true + after_save :trace + + attr_accessor :logger + + def trace + logger << :root + end +end + +class EmbeddedOnce + include Mongoid::Document + embeds_many :embedded_twice, cascade_callbacks: true + embedded_in :root + after_save :trace + + attr_accessor :logger + + def trace + logger << :embedded_once + end +end + +class EmbeddedTwice + include Mongoid::Document + embedded_in :embedded_once + after_save :trace + + attr_accessor :logger + + def trace + logger << :embedded_twice + end +end diff --git a/spec/integration/callbacks_spec.rb b/spec/integration/callbacks_spec.rb index 62a581326..ebf4c856b 100644 --- a/spec/integration/callbacks_spec.rb +++ b/spec/integration/callbacks_spec.rb @@ -554,4 +554,31 @@ def will_save_change_to_attribute_values_before ) end end + + context 'nested embedded documents' do + config_override :prevent_multiple_calls_of_embedded_callbacks, true + + let(:logger) { [] } + + let(:root) do + Root.new( + embedded_once: [ + EmbeddedOnce.new( + embedded_twice: [EmbeddedTwice.new] + ) + ] + ) + end + + before do + root.logger = logger + root.embedded_once.first.logger = logger + root.embedded_once.first.embedded_twice.first.logger = logger + end + + it 'runs callbacks in the correct order' do + root.save! + expect(logger).to eq(%i[embedded_twice embedded_once root]) + end + end end diff --git a/spec/integration/encryption_spec.rb b/spec/integration/encryption_spec.rb index 33b18689c..1ecaa5024 100644 --- a/spec/integration/encryption_spec.rb +++ b/spec/integration/encryption_spec.rb @@ -34,16 +34,20 @@ around do |example| Mongoid.default_client[Crypt::Patient.collection_name].drop + Mongoid.default_client[Crypt::Car.collection_name].drop existing_key_id = Crypt::Patient.encrypt_metadata[:key_id] Crypt::Patient.set_key_id(data_key_id) + Crypt::Car.set_key_id(data_key_id) Mongoid::Config.send(:clients=, config) Mongoid::Clients.with_name(:key_vault)[key_vault_collection].drop Crypt::Patient.store_in(client: :encrypted) + Crypt::Car.store_in(client: :encrypted, database: Crypt::Car.storage_options[:database]) example.run Crypt::Patient.reset_storage_options! Crypt::Patient.set_key_id(existing_key_id) + Crypt::Car.set_key_id(existing_key_id) end it 'encrypts and decrypts fields' do @@ -51,6 +55,7 @@ code: '12345', medical_records: %w[one two three], blood_type: 'A+', + blood_type_key_name: key_alt_name, ssn: 123456789, insurance: Crypt::Insurance.new(policy_number: 123456789) ) @@ -68,6 +73,7 @@ code: '12345', medical_records: %w[one two three], blood_type: 'A+', + blood_type_key_name: key_alt_name, ssn: 123456789, insurance: Crypt::Insurance.new(policy_number: 123456789) ) @@ -81,4 +87,13 @@ end end + it 'stores data encrypted in the non-default database' do + car = Crypt::Car.create!(vin: 'VA1234') + unencrypted_client + .use(Crypt::Car.storage_options[:database])[Crypt::Car.collection.name] + .find(_id: car.id).first.tap do |doc| + expect(doc[:vin]).to be_a(BSON::Binary) + expect(doc[:vin].type).to eq(:ciphertext) + end + end end diff --git a/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb b/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb index 220fa2860..0efc33614 100644 --- a/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb +++ b/spec/mongoid/association/embedded/embeds_many/proxy_spec.rb @@ -1861,51 +1861,56 @@ class TrackingIdValidationHistory end end - describe '#delete' do - - let(:person) do - Person.new - end + %i[delete delete_one].each do |method| + describe "##{method}" do + let(:address_one) { Address.new(street: 'first') } + let(:address_two) { Address.new(street: 'second') } - let(:address_one) do - Address.new(street: 'first') - end + before do + person.addresses << [address_one, address_two] + end - let(:address_two) do - Address.new(street: 'second') - end + shared_examples_for 'deleting from the collection' do + context 'when the document exists in the relation' do + let!(:deleted) do + person.addresses.send(method, address_one) + end - before do - person.addresses << [address_one, address_two] - end + it 'deletes the document' do + expect(person.addresses).to eq([address_two]) + expect(person.reload.addresses).to eq([address_two]) if person.persisted? + end - context 'when the document exists in the relation' do + it 'deletes the document from the unscoped' do + expect(person.addresses.send(:_unscoped)).to eq([address_two]) + end - let!(:deleted) do - person.addresses.delete(address_one) - end + it 'reindexes the relation' do + expect(address_two._index).to eq(0) + end - it 'deletes the document' do - expect(person.addresses).to eq([address_two]) - end + it 'returns the document' do + expect(deleted).to eq(address_one) + end + end - it 'deletes the document from the unscoped' do - expect(person.addresses.send(:_unscoped)).to eq([address_two]) + context 'when the document does not exist' do + it 'returns nil' do + expect(person.addresses.send(method, Address.new)).to be_nil + end + end end - it 'reindexes the relation' do - expect(address_two._index).to eq(0) - end + context 'when the root document is unpersisted' do + let(:person) { Person.new } - it 'returns the document' do - expect(deleted).to eq(address_one) + it_behaves_like 'deleting from the collection' end - end - context 'when the document does not exist' do + context 'when the root document is persisted' do + let(:person) { Person.create } - it 'returns nil' do - expect(person.addresses.delete(Address.new)).to be_nil + it_behaves_like 'deleting from the collection' end end end diff --git a/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb b/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb index 68838a3b2..9c4811352 100644 --- a/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb +++ b/spec/mongoid/association/referenced/has_and_belongs_to_many/proxy_spec.rb @@ -2097,160 +2097,107 @@ end end - describe '#delete' do - - let(:person) do - Person.create! - end - - let(:preference_one) do - Preference.create!(name: 'Testing') - end - - let(:preference_two) do - Preference.create!(name: 'Test') - end - - before do - person.preferences << [preference_one, preference_two] - end - - context 'when the document exists' do - - let!(:deleted) do - person.preferences.delete(preference_one) - end - - it 'removes the document from the relation' do - expect(person.preferences).to eq([preference_two]) - end - - it 'returns the document' do - expect(deleted).to eq(preference_one) - end - - it 'removes the document key from the foreign key' do - expect(person.preference_ids).to eq([preference_two.id]) - end - - it 'removes the inverse reference' do - expect(deleted.reload.people).to be_empty - end + %i[delete delete_one].each do |method| + describe "##{method}" do + let(:person) { Person.create! } + let(:preference_one) { Preference.create!(name: 'Testing') } + let(:preference_two) { Preference.create!(name: 'Test') } - it 'removes the base id from the inverse keys' do - expect(deleted.reload.person_ids).to be_empty + before do + person.preferences << [preference_one, preference_two] end - context 'and person and preferences are reloaded' do - - before do - person.reload - preference_one.reload - preference_two.reload + context 'when the document exists' do + let!(:deleted) do + person.preferences.send(method, preference_one) end - it 'nullifies the deleted preference' do + it 'removes the document from the relation' do expect(person.preferences).to eq([preference_two]) end - it 'retains the ids for one preference' do - expect(person.preference_ids).to eq([preference_two.id]) + it 'returns the document' do + expect(deleted).to eq(preference_one) end - end - end - - context 'when the document does not exist' do - - let!(:deleted) do - person.preferences.delete(Preference.new) - end - - it 'returns nil' do - expect(deleted).to be_nil - end - - it 'does not modify the relation' do - expect(person.preferences).to eq([preference_one, preference_two]) - end - - it 'does not modify the keys' do - expect(person.preference_ids).to eq([preference_one.id, preference_two.id]) - end - end - context 'when :dependent => :nullify is set' do - - context 'when :inverse_of is set' do - - let(:event) do - Event.create! + it 'removes the document key from the foreign key' do + expect(person.preference_ids).to eq([preference_two.id]) end - before do - person.administrated_events << [event] + it 'removes the inverse reference' do + expect(deleted.reload.people).to be_empty end - it 'deletes the document' do - expect(event.delete).to be true + it 'removes the base id from the inverse keys' do + expect(deleted.reload.person_ids).to be_empty end - end - end - - context 'when the relationships are self referencing' do - let(:tag_one) do - Tag.create!(text: 'one') - end + context 'and person and preferences are reloaded' do + before do + person.reload + preference_one.reload + preference_two.reload + end - let(:tag_two) do - Tag.create!(text: 'two') - end + it 'nullifies the deleted preference' do + expect(person.preferences).to eq([preference_two]) + end - before do - tag_one.related << tag_two + it 'retains the ids for one preference' do + expect(person.preference_ids).to eq([preference_two.id]) + end + end end - context 'when deleting without reloading' do - + context 'when the document does not exist' do let!(:deleted) do - tag_one.related.delete(tag_two) + person.preferences.send(method, Preference.new) end - it 'deletes the document from the relation' do - expect(tag_one.related).to be_empty + it 'returns nil' do + expect(deleted).to be_nil end - it 'deletes the foreign key from the relation' do - expect(tag_one.related_ids).to be_empty + it 'does not modify the relation' do + expect(person.preferences).to eq([preference_one, preference_two]) end - it 'removes the reference from the inverse' do - expect(deleted.related).to be_empty - end - - it 'removes the foreign keys from the inverse' do - expect(deleted.related_ids).to be_empty + it 'does not modify the keys' do + expect(person.preference_ids).to eq([preference_one.id, preference_two.id]) end end - context 'when deleting with reloading' do - - context 'when deleting from the front side' do + context 'when :dependent => :nullify is set' do + context 'when :inverse_of is set' do + let(:event) { Event.create! } - let(:reloaded) do - tag_one.reload + before do + person.administrated_events << [event] end - let!(:deleted) do - reloaded.related.delete(tag_two) + it 'deletes the document' do + expect(event.delete).to be true end + end + end + + context 'when the relationships are self referencing' do + let(:tag_one) { Tag.create!(text: 'one') } + let(:tag_two) { Tag.create!(text: 'two') } + + before do + tag_one.related << tag_two + end + + context 'when deleting without reloading' do + let!(:deleted) { tag_one.related.send(method, tag_two) } it 'deletes the document from the relation' do - expect(reloaded.related).to be_empty + expect(tag_one.related).to be_empty end it 'deletes the foreign key from the relation' do - expect(reloaded.related_ids).to be_empty + expect(tag_one.related_ids).to be_empty end it 'removes the reference from the inverse' do @@ -2262,107 +2209,106 @@ end end - context 'when deleting from the inverse side' do + context 'when deleting with reloading' do + context 'when deleting from the front side' do + let(:reloaded) { tag_one.reload } + let!(:deleted) { reloaded.related.send(method, tag_two) } - let(:reloaded) do - tag_two.reload - end - - let!(:deleted) do - reloaded.related.delete(tag_one) - end + it 'deletes the document from the relation' do + expect(reloaded.related).to be_empty + end - it 'deletes the document from the relation' do - expect(reloaded.related).to be_empty - end + it 'deletes the foreign key from the relation' do + expect(reloaded.related_ids).to be_empty + end - it 'deletes the foreign key from the relation' do - expect(reloaded.related_ids).to be_empty - end + it 'removes the reference from the inverse' do + expect(deleted.related).to be_empty + end - it 'removes the foreign keys from the inverse' do - expect(deleted.related_ids).to be_empty + it 'removes the foreign keys from the inverse' do + expect(deleted.related_ids).to be_empty + end end - end - end - end - context 'when the association has callbacks' do + context 'when deleting from the inverse side' do + let(:reloaded) { tag_two.reload } + let!(:deleted) { reloaded.related.send(method, tag_one) } - let(:post) do - Post.new - end + it 'deletes the document from the relation' do + expect(reloaded.related).to be_empty + end - let(:tag) do - Tag.new - end + it 'deletes the foreign key from the relation' do + expect(reloaded.related_ids).to be_empty + end - before do - post.tags << tag + it 'removes the foreign keys from the inverse' do + expect(deleted.related_ids).to be_empty + end + end + end end - context 'when the callback is a before_remove' do + context 'when the association has callbacks' do + let(:post) { Post.new } + let(:tag) { Tag.new } - context 'when there are no errors' do + before do + post.tags << tag + end - before do - post.tags.delete tag - end + context 'when the callback is a before_remove' do + context 'when there are no errors' do + before do + post.tags.send(method, tag) + end - it 'executes the callback' do - expect(post.before_remove_called).to be true - end + it 'executes the callback' do + expect(post.before_remove_called).to be true + end - it 'removes the document from the relation' do - expect(post.tags).to be_empty + it 'removes the document from the relation' do + expect(post.tags).to be_empty + end end - end - - context 'when errors are raised' do - before do - expect(post).to receive(:before_remove_tag).and_raise - begin - post.tags.delete(tag) - rescue StandardError + context 'when errors are raised' do + before do + expect(post).to receive(:before_remove_tag).and_raise + begin; post.tags.send(method, tag); rescue StandardError; end end - end - it 'does not remove the document from the relation' do - expect(post.tags).to eq([tag]) + it 'does not remove the document from the relation' do + expect(post.tags).to eq([tag]) + end end end - end - - context 'when the callback is an after_remove' do - - context 'when no errors are raised' do - before do - post.tags.delete(tag) - end + context 'when the callback is an after_remove' do + context 'when no errors are raised' do + before do + post.tags.send(method, tag) + end - it 'executes the callback' do - expect(post.after_remove_called).to be true - end + it 'executes the callback' do + expect(post.after_remove_called).to be true + end - it 'removes the document from the relation' do - expect(post.tags).to be_empty + it 'removes the document from the relation' do + expect(post.tags).to be_empty + end end - end - context 'when errors are raised' do - - before do - expect(post).to receive(:after_remove_tag).and_raise - begin - post.tags.delete(tag) - rescue StandardError + context 'when errors are raised' do + before do + expect(post).to receive(:after_remove_tag).and_raise + begin; post.tags.send(method, tag); rescue StandardError; end end - end - it 'removes the document from the relation' do - expect(post.tags).to be_empty + it 'removes the document from the relation' do + expect(post.tags).to be_empty + end end end end @@ -2370,16 +2316,12 @@ end %i[delete_all destroy_all].each do |method| - describe "##{method}" do context 'when the relation is not polymorphic' do context 'when conditions are provided' do - - let(:person) do - Person.create! - end + let(:person) { Person.create! } let!(:preference_one) do person.preferences.create!(name: 'Testing') diff --git a/spec/mongoid/association/referenced/has_many/proxy_spec.rb b/spec/mongoid/association/referenced/has_many/proxy_spec.rb index dde3b0fbe..9f667ff93 100644 --- a/spec/mongoid/association/referenced/has_many/proxy_spec.rb +++ b/spec/mongoid/association/referenced/has_many/proxy_spec.rb @@ -2,6 +2,26 @@ require 'spec_helper' +module RefHasManySpec + module OverrideInitialize + class Parent + include Mongoid::Document + has_many :children, inverse_of: :parent + end + + class Child + include Mongoid::Document + belongs_to :parent + field :name, type: String + + def initialize(*args) + super + self.name ||= 'default' + end + end + end +end + describe Mongoid::Association::Referenced::HasMany::Proxy do config_override :raise_not_found_error, true @@ -16,18 +36,11 @@ end %i[<< push].each do |method| - describe "##{method}" do - context 'when providing the base class in child constructor' do + let(:person) { Person.create! } - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.send(method, Post.new(person: person)) - end + before { person.posts.send(method, Post.new(person: person)) } it 'only adds the association once' do expect(person.posts.size).to eq(1) @@ -39,22 +52,12 @@ end context 'when the associations are not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end + let(:person) { Person.new } context 'when the child is new' do - - let(:post) do - Post.new - end - - let!(:added) do - person.posts.send(method, post) - end + let(:post) { Post.new } + let!(:added) { person.posts.send(method, post) } it 'sets the foreign key on the association' do expect(post.person_id).to eq(person.id) @@ -82,10 +85,7 @@ end context 'when the child is persisted' do - - let(:post) do - Post.create! - end + let(:post) { Post.create! } before do person.posts.send(method, post) @@ -112,7 +112,6 @@ end context 'when subsequently saving the parent' do - before do person.save! post.save! @@ -126,11 +125,7 @@ end context 'when appending in a parent create block' do - - let!(:post) do - Post.create!(title: 'testing') - end - + let!(:post) { Post.create!(title: 'testing') } let!(:person) do Person.create! do |doc| doc.posts << post @@ -159,14 +154,8 @@ end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end - - let(:post) do - Post.new - end + let(:person) { Person.create! } + let(:post) { Post.new } before do person.posts.send(method, post) @@ -202,10 +191,7 @@ end context 'when the related item has embedded associations' do - - let!(:user) do - User.create! - end + let!(:user) { User.create! } before do p = Post.create!(roles: [Role.create!]) @@ -220,7 +206,6 @@ end context 'when saving another post' do - before do person.posts.send(method, Post.new) end @@ -231,10 +216,7 @@ end context 'when documents already exist on the association' do - - let(:post_two) do - Post.new(title: 'Test') - end + let(:post_two) { Post.new(title: 'Test') } before do person.posts.send(method, post_two) @@ -276,16 +258,10 @@ end context 'when.adding to the association' do - - let(:person) do - Person.create! - end + let(:person) { Person.create! } context 'when the operation succeeds' do - - let(:post) do - Post.new - end + let(:post) { Post.new } before do person.posts.send(method, post) @@ -297,36 +273,20 @@ end context 'when the operation fails' do - - let!(:existing) do - Post.create! - end - - let(:post) do - Post.new do |doc| - doc._id = existing.id - end - end + let!(:existing) { Post.create! } + let(:post) { Post.new { |doc| doc._id = existing.id } } it 'raises an error' do - expect do - person.posts.send(method, post) - end.to raise_error(Mongo::Error::OperationFailure) + expect { person.posts.send(method, post) } + .to raise_error(Mongo::Error::OperationFailure) end end end context 'when the associations are polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.new } + let(:rating) { Rating.new } before do movie.ratings.send(method, rating) @@ -350,14 +310,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.create! } + let(:rating) { Rating.new } before do movie.ratings.send(method, rating) @@ -384,18 +338,10 @@ end describe '#=' do - context 'when the association is not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end - - let(:post) do - Post.new - end + let(:person) { Person.new } + let(:post) { Post.new } before do person.posts = [post] @@ -419,14 +365,8 @@ end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end - - let(:post) do - Post.new - end + let(:person) { Person.create! } + let(:post) { Post.new } before do person.posts = [post] @@ -449,9 +389,7 @@ end context 'when replacing the association with the same documents' do - context 'when using the same in memory instance' do - before do person.posts = [post] end @@ -466,10 +404,7 @@ end context 'when using a new instance' do - - let(:from_db) do - Person.find(person.id) - end + let(:from_db) { Person.find(person.id) } before do from_db.posts = [post] @@ -486,13 +421,9 @@ end context 'when replacing the with a combination of old and new docs' do - - let(:new_post) do - Post.create!(title: 'new post') - end + let(:new_post) { Post.create!(title: 'new post') } context 'when using the same in memory instance' do - before do person.posts = [post, new_post] end @@ -515,10 +446,7 @@ end context 'when using a new instance' do - - let(:from_db) do - Person.find(person.id) - end + let(:from_db) { Person.find(person.id) } before do from_db.posts = [post, new_post] @@ -535,13 +463,9 @@ end context 'when replacing the with a combination of only new docs' do - - let(:new_post) do - Post.create!(title: 'new post') - end + let(:new_post) { Post.create!(title: 'new post') } context 'when using the same in memory instance' do - before do person.posts = [new_post] end @@ -556,10 +480,7 @@ end context 'when using a new instance' do - - let(:from_db) do - Person.find(person.id) - end + let(:from_db) { Person.find(person.id) } before do from_db.posts = [new_post] @@ -578,16 +499,9 @@ end context 'when the association is polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.new } + let(:rating) { Rating.new } before do movie.ratings = [rating] @@ -611,14 +525,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.create! } + let(:rating) { Rating.new } before do movie.ratings = [rating] @@ -644,19 +552,11 @@ end describe '#= []' do - context 'when the parent is persisted' do - - let(:posts) do - [Post.create!(title: '1'), Post.create!(title: '2')] - end - - let(:person) do - Person.create!(posts: posts) - end + let(:posts) { [Post.create!(title: '1'), Post.create!(title: '2')] } + let(:person) { Person.create!(posts: posts) } context 'when the parent has multiple children' do - before do person.posts = [] end @@ -673,18 +573,10 @@ end describe '#= nil' do - context 'when the association is not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end - - let(:post) do - Post.new - end + let(:person) { Person.new } + let(:post) { Post.new } before do person.posts = [post] @@ -705,16 +597,10 @@ end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end + let(:person) { Person.create! } context 'when dependent is destructive' do - - let(:post) do - Post.new - end + let(:post) { Post.new } before do person.posts = [post] @@ -739,10 +625,7 @@ end context 'when dependent is not destructive' do - - let(:drug) do - Drug.new(name: 'Oxycodone') - end + let(:drug) { Drug.new(name: 'Oxycodone') } before do person.drugs = [drug] @@ -769,16 +652,9 @@ end context 'when the association is polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.new } + let(:rating) { Rating.new } before do movie.ratings = [rating] @@ -799,14 +675,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.create! } + let(:rating) { Rating.new } before do movie.ratings = [rating] @@ -826,7 +696,6 @@ end context 'when dependent is nullify' do - it 'does not delete the target from the database' do expect(rating).to_not be_destroyed end @@ -835,19 +704,11 @@ end end - describe "#\{name}_ids=" do - - let(:person) do - Person.new - end - - let(:post_one) do - Post.create! - end + describe '#\{name}_ids=' do + let(:person) { Person.new } + let(:post_one) { Post.create! } - let(:post_two) do - Post.create! - end + let(:post_two) { Post.create! } before do person.post_ids = [post_one.id, post_two.id] @@ -858,15 +719,9 @@ end end - describe "#\{name}_ids" do - - let(:posts) do - [Post.create!, Post.create!] - end - - let(:person) do - Person.create!(posts: posts) - end + describe '#\{name}_ids' do + let(:posts) { [Post.create!, Post.create!] } + let(:person) { Person.create!(posts: posts) } it 'returns ids of documents that are in the association' do expect(person.post_ids).to eq(posts.map(&:id)) @@ -874,20 +729,20 @@ end %i[build new].each do |method| - describe "##{method}" do + context 'when model has #initialize' do + let(:parent) { RefHasManySpec::OverrideInitialize::Parent.create } + let(:child) { parent.children.send(method) } - context 'when the association is not polymorphic' do + it 'calls #initialize' do + expect(child.name).to be == 'default' + end + end + context 'when the association is not polymorphic' do context 'when the parent is a new record' do - - let(:person) do - Person.new(title: 'sir') - end - - let!(:post) do - person.posts.send(method, title: '$$$') - end + let(:person) { Person.new(title: 'sir') } + let!(:post) { person.posts.send(method, title: '$$$') } it 'sets the foreign key on the association' do expect(post.person_id).to eq(person.id) @@ -919,14 +774,8 @@ end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.send(method, text: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.send(method, text: 'Testing') } it 'sets the foreign key on the association' do expect(post.person_id).to eq(person.id) @@ -951,16 +800,9 @@ end context 'when the association is polymorphic' do - context 'when the parent is a subclass' do - - let(:video_game) do - VideoGame.create! - end - - let(:rating) do - video_game.ratings.build - end + let(:video_game) { VideoGame.create! } + let(:rating) { video_game.ratings.build } it 'sets the parent on the child' do expect(rating.ratable).to eq(video_game) @@ -972,14 +814,8 @@ end context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let!(:rating) do - movie.ratings.send(method, value: 3) - end + let(:movie) { Movie.new } + let!(:rating) { movie.ratings.send(method, value: 3) } it 'sets the foreign key on the association' do expect(rating.ratable_id).to eq(movie.id) @@ -1007,14 +843,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.send(method, value: 4) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.send(method, value: 4) } it 'sets the foreign key on the association' do expect(rating.ratable_id).to eq(movie.id) @@ -1041,24 +871,13 @@ end describe '#clear' do - context 'when the association is not polymorphic' do - context 'when the parent has been persisted' do - - let!(:person) do - Person.create! - end + let!(:person) { Person.create! } context 'when the children are persisted' do - - let!(:post) do - person.posts.create!(title: 'Testing') - end - - let!(:association) do - person.posts.clear - end + let!(:post) { person.posts.create!(title: 'Testing') } + let!(:association) { person.posts.clear } it 'clears out the association' do expect(person.posts).to be_empty @@ -1078,12 +897,8 @@ end context 'when the children are not persisted' do - - let!(:post) do + before do person.posts.build(title: 'Testing') - end - - let!(:association) do person.posts.clear end @@ -1094,16 +909,10 @@ end context 'when the parent is not persisted' do + let(:person) { Person.new } - let(:person) do - Person.new - end - - let!(:post) do + before do person.posts.build(title: 'Testing') - end - - let!(:association) do person.posts.clear end @@ -1114,22 +923,12 @@ end context 'when the association is polymorphic' do - context 'when the parent has been persisted' do - - let!(:movie) do - Movie.create! - end + let!(:movie) { Movie.create! } context 'when the children are persisted' do - - let!(:rating) do - movie.ratings.create!(value: 1) - end - - let!(:association) do - movie.ratings.clear - end + let!(:rating) { movie.ratings.create!(value: 1) } + let!(:association) { movie.ratings.clear } it 'clears out the association' do expect(movie.ratings).to be_empty @@ -1149,12 +948,8 @@ end context 'when the children are not persisted' do - - let!(:rating) do + before do movie.ratings.build(value: 3) - end - - let!(:association) do movie.ratings.clear end @@ -1165,16 +960,10 @@ end context 'when the parent is not persisted' do + let(:movie) { Movie.new } - let(:movie) do - Movie.new - end - - let!(:rating) do + before do movie.ratings.build(value: 2) - end - - let!(:association) do movie.ratings.clear end @@ -1186,21 +975,13 @@ end describe '#concat' do - context 'when the associations are not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end - - let(:post) do - Post.new - end + let(:person) { Person.new } + let(:post) { Post.new } before do - person.posts.push(post) + person.posts.push post end it 'sets the foreign key on the association' do @@ -1225,14 +1006,10 @@ end context 'when appending in a parent create block' do - - let!(:post) do - Post.create!(title: 'testing') - end - + let!(:post) { Post.create!(title: 'testing') } let!(:person) do Person.create! do |doc| - doc.posts.push(post) + doc.posts.push post end end @@ -1258,21 +1035,13 @@ end context 'when the parent is not a new record' do + let(:person) { Person.create! } + let(:post) { Post.new } - let(:person) do - Person.create! - end - - let(:post) do - Post.new - end - - let(:post_three) do - Post.new - end + let(:post_three) { Post.new } before do - person.posts.push(post, post_three) + person.posts.push post, post_three end it 'sets the foreign key on the association' do @@ -1296,13 +1065,10 @@ end context 'when documents already exist on the association' do - - let(:post_two) do - Post.new(title: 'Test') - end + let(:post_two) { Post.new(title: 'Test') } before do - person.posts.push(post_two) + person.posts.push post_two end it 'sets the foreign key on the association' do @@ -1338,19 +1104,12 @@ end context 'when the associations are polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.new } + let(:rating) { Rating.new } before do - movie.ratings.push(rating) + movie.ratings.push rating end it 'sets the foreign key on the association' do @@ -1371,17 +1130,11 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let(:rating) do - Rating.new - end + let(:movie) { Movie.create! } + let(:rating) { Rating.new } before do - movie.ratings.push(rating) + movie.ratings.push rating end it 'sets the foreign key on the association' do @@ -1403,16 +1156,10 @@ end describe '#count' do - - let(:movie) do - Movie.create! - end + let(:movie) { Movie.create! } context 'when documents have been persisted' do - - let!(:rating) do - movie.ratings.create!(value: 1) - end + before { movie.ratings.create!(value: 1) } it 'returns the number of persisted documents' do expect(movie.ratings.count).to eq(1) @@ -1425,10 +1172,7 @@ end context 'when documents have not been persisted' do - - let!(:rating) do - movie.ratings.build(value: 1) - end + before { movie.ratings.build(value: 1) } it 'returns 0' do expect(movie.ratings.count).to eq(0) @@ -1440,7 +1184,6 @@ end context 'when mixed persisted and unpersisted documents' do - before do movie.ratings.create(value: 1) movie.ratings.build(value: 2) @@ -1457,16 +1200,13 @@ end context 'when no document is added' do - it 'returns false' do expect(movie.ratings.any?).to be false end end context 'when new documents exist in the database' do - context 'when the documents are part of the association' do - before do Rating.create!(ratable: movie) end @@ -1477,7 +1217,6 @@ end context 'when the documents are not part of the association' do - before do Rating.create! end @@ -1490,67 +1229,41 @@ end describe '#any?' do - shared_examples 'does not query database when association is loaded' do - - let(:fresh_movie) { Movie.find(movie.id) } + let!(:fresh_movie) { Movie.find(movie.id) } context 'when association is not loaded' do it 'queries database on each call' do - fresh_movie - - expect_query(1) do - expect(fresh_movie.ratings.any?).to be expected_result - end - - expect_query(1) do - expect(fresh_movie.ratings.any?).to be expected_result - end + expect_query(1) { expect(fresh_movie.ratings.any?).to be expected_result } + expect_query(1) { expect(fresh_movie.ratings.any?).to be expected_result } end context 'when using a block' do - it 'queries database on first call only' do - fresh_movie - - expect_query(1) do - expect(fresh_movie.ratings.any? { false }).to be false - end + def fresh_movie_ratings? + fresh_movie.ratings.any? { false } + end - expect_no_queries do - expect(fresh_movie.ratings.any? { false }).to be false - end + it 'queries database on first call only' do + expect_query(1) { expect(fresh_movie_ratings?).to be false } + expect_no_queries { expect(fresh_movie_ratings?).to be false } end end end context 'when association is loaded' do it 'does not query database' do - fresh_movie - - expect_query(1) do - expect(fresh_movie.ratings.any?).to be expected_result - end - + expect_query(1) { expect(fresh_movie.ratings.any?).to be expected_result } fresh_movie.ratings.to_a - - expect_no_queries do - expect(fresh_movie.ratings.any?).to be expected_result - end + expect_no_queries { expect(fresh_movie.ratings.any?).to be expected_result } end end end - let(:movie) do - Movie.create! - end + let(:movie) { Movie.create! } context 'when nothing exists on the association' do - context 'when no document is added' do - - let!(:movie) do - Movie.create! - end + let!(:movie) { Movie.create! } let(:expected_result) { false } it 'returns false' do @@ -1561,14 +1274,11 @@ end context 'when the document is destroyed' do - before do Rating.create! end - let!(:movie) do - Movie.create! - end + let!(:movie) { Movie.create! } it 'returns false' do movie.destroy @@ -1578,10 +1288,7 @@ end context 'when appending to a association and _loaded/_unloaded are empty' do - - let!(:movie) do - Movie.create! - end + let!(:movie) { Movie.create! } before do movie.ratings << Rating.new @@ -1592,7 +1299,7 @@ end context 'when association is not loaded' do - before do + it do expect(movie.ratings._loaded?).to be false end @@ -1605,42 +1312,38 @@ context 'when association is loaded' do it 'does not query database' do - expect_no_queries do - expect(movie.ratings.any?).to be true - end - + expect_no_queries { expect(movie.ratings.any?).to be true } movie.ratings.to_a - - expect_no_queries do - expect(movie.ratings.any?).to be true - end + expect_no_queries { expect(movie.ratings.any?).to be true } end end end - context 'when appending to a association in a transaction' do + context 'when appending to an association in a transaction' do require_transaction_support - let!(:movie) do - Movie.create! + let!(:movie) { Movie.create! } + + def with_transaction_via(model, &block) + model.with_session do |session| + session.with_transaction(&block) + end end it 'returns true' do - movie.with_session do |session| - session.with_transaction do - expect { movie.ratings << Rating.new }.to_not raise_error - expect(movie.ratings.any?).to be true - end + with_transaction_via(movie) do + expect { movie.ratings << Rating.new }.to_not raise_error + expect(movie.ratings.any?).to be true end end end context 'when documents have been persisted' do + let(:expected_result) { true } - let!(:rating) do + before do movie.ratings.create!(value: 1) end - let(:expected_result) { true } it 'returns true' do expect(movie.ratings.any?).to be true @@ -1650,8 +1353,7 @@ end context 'when documents have not been persisted' do - - let!(:rating) do + before do movie.ratings.build(value: 1) end @@ -1672,16 +1374,9 @@ end describe '#create' do - context 'when providing multiple attributes' do - - let(:person) do - Person.create! - end - - let!(:posts) do - person.posts.create!([{ text: 'Test1' }, { text: 'Test2' }]) - end + let(:person) { Person.create! } + let!(:posts) { person.posts.create!([{ text: 'Test1' }, { text: 'Test2' }]) } it 'creates multiple documents' do expect(posts.size).to eq(2) @@ -1701,16 +1396,9 @@ end context 'when the association is not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end - - let(:post) do - person.posts.create!(text: 'Testing') - end + let(:person) { Person.new } + let(:post) { person.posts.create!(text: 'Testing') } it 'raises an unsaved document error' do expect { post }.to raise_error(Mongoid::Errors::UnsavedDocument) @@ -1718,16 +1406,9 @@ end context 'when.creating the document' do - context 'when the operation is successful' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(text: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(text: 'Testing') } it 'creates the document' do expect(person.posts).to eq([post]) @@ -1735,31 +1416,18 @@ end context 'when the operation fails' do - - let(:person) do - Person.create! - end - - let!(:existing) do - Post.create! - end + let(:person) { Person.create! } + let!(:existing) { Post.create! } it 'raises an error' do - expect do - person.posts.create! do |doc| - doc._id = existing.id - end - end.to raise_error(Mongo::Error::OperationFailure) + expect { person.posts.create! { |doc| doc._id = existing.id } } + .to raise_error(Mongo::Error::OperationFailure) end end end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end - + let(:person) { Person.create! } let!(:post) do person.posts.create!(text: 'Testing') do |post| post.content = 'The Content' @@ -1792,12 +1460,9 @@ end context 'when passing a new object' do + let!(:odd) { Odd.create!(name: 'one') } - let!(:odd) do - Odd.create!(name: 'one') - end - - let!(:even) do + before do odd.evens.create!(name: 'two', odds: [Odd.new(name: 'three')]) end @@ -1820,16 +1485,9 @@ end context 'when the association is polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - movie.ratings.create!(value: 1) - end + let(:movie) { Movie.new } + let(:rating) { movie.ratings.create!(value: 1) } it 'raises an unsaved document error' do expect { rating }.to raise_error(Mongoid::Errors::UnsavedDocument) @@ -1837,14 +1495,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.create!(value: 3) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.create!(value: 3) } it 'sets the foreign key on the association' do expect(rating.ratable_id).to eq(movie.id) @@ -1869,14 +1521,8 @@ end context 'when using a different primary_key' do - - let(:person) do - Person.create!(username: 'arthurnn') - end - - let(:drug) do - person.drugs.create! - end + let(:person) { Person.create!(username: 'arthurnn') } + let(:drug) { person.drugs.create! } it 'saves pk value on fk field' do expect(drug.person_id).to eq('arthurnn') @@ -1885,16 +1531,9 @@ end describe '#create!' do - context 'when providing multiple attributes' do - - let(:person) do - Person.create! - end - - let!(:posts) do - person.posts.create!([{ text: 'Test1' }, { text: 'Test2' }]) - end + let(:person) { Person.create! } + let!(:posts) { person.posts.create!([{ text: 'Test1' }, { text: 'Test2' }]) } it 'creates multiple documents' do expect(posts.size).to eq(2) @@ -1914,16 +1553,9 @@ end context 'when the association is not polymorphic' do - context 'when the parent is a new record' do - - let(:person) do - Person.new - end - - let(:post) do - person.posts.create!(title: 'Testing') - end + let(:person) { Person.new } + let(:post) { person.posts.create!(title: 'Testing') } it 'raises an unsaved document error' do expect { post }.to raise_error(Mongoid::Errors::UnsavedDocument) @@ -1931,14 +1563,8 @@ end context 'when the parent is not a new record' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(title: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Testing') } it 'sets the foreign key on the association' do expect(post.person_id).to eq(person.id) @@ -1961,27 +1587,18 @@ end context 'when validation fails' do - it 'raises an error' do - expect do - person.posts.create!(title: '$$$') - end.to raise_error(Mongoid::Errors::Validations) + expect { person.posts.create!(title: '$$$') } + .to raise_error(Mongoid::Errors::Validations) end end end end context 'when the association is polymorphic' do - context 'when the parent is a new record' do - - let(:movie) do - Movie.new - end - - let(:rating) do - movie.ratings.create!(value: 1) - end + let(:movie) { Movie.new } + let(:rating) { movie.ratings.create!(value: 1) } it 'raises an unsaved document error' do expect { rating }.to raise_error(Mongoid::Errors::UnsavedDocument) @@ -1989,14 +1606,8 @@ end context 'when the parent is not a new record' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.create!(value: 4) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.create!(value: 4) } it 'sets the foreign key on the association' do expect(rating.ratable_id).to eq(movie.id) @@ -2019,11 +1630,9 @@ end context 'when validation fails' do - it 'raises an error' do - expect do - movie.ratings.create!(value: 1000) - end.to raise_error(Mongoid::Errors::Validations) + expect { movie.ratings.create!(value: 1000) } + .to raise_error(Mongoid::Errors::Validations) end end end @@ -2031,42 +1640,24 @@ end describe '#criteria' do - - let(:base) do - Movie.new - end + let(:base) { Movie.new } context 'when the association is polymorphic' do - - let(:association) do - Movie.relations['ratings'] - end - - let(:criteria) do - association.criteria(base) - end + let(:association) { Movie.relations['ratings'] } + let(:criteria) { association.criteria(base) } it 'includes the type in the criteria' do - expect(criteria.selector).to eq({ - 'ratable_id' => base.id, - 'ratable_type' => 'Movie' - }) + expect(criteria.selector).to eq( + { 'ratable_id' => base.id, 'ratable_type' => 'Movie' } + ) end end context 'when the association is not polymorphic' do + let(:association) { Person.relations['posts'] } + let(:base) { Person.new } - let(:association) do - Person.relations['posts'] - end - - let(:base) do - Person.new - end - - let(:criteria) do - association.criteria(base) - end + let(:criteria) { association.criteria(base) } it 'does not include the type in the criteria' do expect(criteria.selector).to eq({ 'person_id' => base.id }) @@ -2074,170 +1665,125 @@ end end - describe '#delete' do - - let!(:person) do - Person.create!(username: 'arthurnn') - end - - context 'when the document is found' do - - context 'when no dependent option is set' do - - context 'when we are assigning attributes' do - - let!(:drug) do - person.drugs.create! - end - let(:deleted) do - person.drugs.delete(drug) - end - - before do - Mongoid::Threaded.begin_execution(:assign) - end - - after do - Mongoid::Threaded.exit_execution(:assign) - end - - it 'does not cascade' do - expect(deleted.changes.keys).to eq(['person_id']) - end - end + %i[delete delete_one].each do |method| + describe "##{method}" do + let!(:person) { Person.create!(username: 'arthurnn') } - context 'when the document is loaded' do + context 'when the document is found' do + context 'when no dependent option is set' do + context 'when we are assigning attributes' do + let!(:drug) { person.drugs.create! } + let(:deleted) { person.drugs.send(method, drug) } - let!(:drug) do - person.drugs.create! - end + before do + Mongoid::Threaded.begin_execution(:assign) + end - let!(:deleted) do - person.drugs.delete(drug) - end + after do + Mongoid::Threaded.exit_execution(:assign) + end - it 'returns the document' do - expect(deleted).to eq(drug) + it 'does not cascade' do + expect(deleted.changes.keys).to eq(['person_id']) + end end - it 'deletes the foreign key' do - expect(drug.person_id).to be_nil - end + context 'when the document is loaded' do + let!(:drug) { person.drugs.create! } + let!(:deleted) { person.drugs.send(method, drug) } - it 'removes the document from the association' do - expect(person.drugs).to_not include(drug) - end - end + it 'returns the document' do + expect(deleted).to eq(drug) + end - context 'when the document is not loaded' do + it 'deletes the foreign key' do + expect(drug.person_id).to be_nil + end - let!(:drug) do - Drug.create!(person_id: person.username) + it 'removes the document from the association' do + expect(person.drugs).to_not include(drug) + end end - let!(:deleted) do - person.drugs.delete(drug) - end + context 'when the document is not loaded' do + let!(:drug) { Drug.create!(person_id: person.username) } + let!(:deleted) { person.drugs.send(method, drug) } - it 'returns the document' do - expect(deleted).to eq(drug) - end + it 'returns the document' do + expect(deleted).to eq(drug) + end - it 'deletes the foreign key' do - expect(drug.person_id).to be_nil - end + it 'deletes the foreign key' do + expect(drug.person_id).to be_nil + end - it 'removes the document from the association' do - expect(person.drugs).to_not include(drug) + it 'removes the document from the association' do + expect(person.drugs).to_not include(drug) + end end end - end - - context 'when dependent is delete' do - - context 'when the document is loaded' do - let!(:post) do - person.posts.create!(title: 'test') - end - - let!(:deleted) do - person.posts.delete(post) - end - - it 'returns the document' do - expect(deleted).to eq(post) - end - - it 'deletes the document' do - expect(post).to be_destroyed - end + context 'when dependent is delete' do + context 'when the document is loaded' do + let!(:post) { person.posts.create!(title: 'test') } + let!(:deleted) { person.posts.send(method, post) } - it 'removes the document from the association' do - expect(person.posts).to_not include(post) - end - end + it 'returns the document' do + expect(deleted).to eq(post) + end - context 'when the document is not loaded' do + it 'deletes the document' do + expect(post).to be_destroyed + end - let!(:post) do - Post.create!(title: 'foo', person_id: person.id) + it 'removes the document from the association' do + expect(person.posts).to_not include(post) + end end - let!(:deleted) do - person.posts.delete(post) - end + context 'when the document is not loaded' do + let!(:post) { Post.create!(title: 'foo', person_id: person.id) } + let!(:deleted) { person.posts.send(method, post) } - it 'returns the document' do - expect(deleted).to eq(post) - end + it 'returns the document' do + expect(deleted).to eq(post) + end - it 'deletes the document' do - expect(post).to be_destroyed - end + it 'deletes the document' do + expect(post).to be_destroyed + end - it 'removes the document from the association' do - expect(person.posts).to_not include(post) + it 'removes the document from the association' do + expect(person.posts).to_not include(post) + end end end end - end - - context 'when the document is not found' do - - let!(:post) do - Post.create!(title: 'foo') - end - let!(:deleted) do - person.posts.delete(post) - end + context 'when the document is not found' do + let!(:post) { Post.create!(title: 'foo') } + let!(:deleted) { person.posts.send(method, post) } - it 'returns nil' do - expect(deleted).to be_nil - end + it 'returns nil' do + expect(deleted).to be_nil + end - it 'does not delete the document' do - expect(post).to be_persisted + it 'does not delete the document' do + expect(post).to be_persisted + end end end end %i[delete_all destroy_all].each do |method| - describe "##{method}" do - context 'when the association is not polymorphic' do - context 'when conditions are provided' do - - let(:person) do - Person.create!(username: 'durran') - end - - let!(:post1) { person.posts.create!(title: 'Testing') } + let(:person) { Person.create!(username: 'durran') } let!(:post2) { person.posts.create!(title: 'Test') } + before { person.posts.create!(title: 'Testing') } + it 'removes the correct posts' do person.posts.send(method, { title: 'Testing' }) expect(person.posts.count).to eq(1) @@ -2260,10 +1806,7 @@ end context 'when conditions are not provided' do - - let(:person) do - Person.create! - end + let(:person) { Person.create! } before do person.posts.create!(title: 'Testing') @@ -2292,16 +1835,12 @@ end context 'when the association is polymorphic' do - context 'when conditions are provided' do - - let(:movie) do - Movie.create!(title: 'Bladerunner') - end - - let!(:rating1) { movie.ratings.create!(value: 1) } + let(:movie) { Movie.create!(title: 'Bladerunner') } let!(:rating2) { movie.ratings.create!(value: 2) } + before { movie.ratings.create!(value: 1) } + it 'removes the correct ratings' do movie.ratings.send(method, { value: 1 }) expect(movie.ratings.count).to eq(1) @@ -2323,10 +1862,7 @@ end context 'when conditions are not provided' do - - let(:movie) do - Movie.create!(title: 'Bladerunner') - end + let(:movie) { Movie.create!(title: 'Bladerunner') } before do movie.ratings.create!(value: 1) @@ -2357,20 +1893,15 @@ end describe '.embedded?' do - it 'returns false' do expect(described_class).to_not be_embedded end end describe '#exists?' do - - let!(:person) do - Person.create! - end + let!(:person) { Person.create! } context 'when documents exist in the database' do - before do person.posts.create! end @@ -2381,33 +1912,21 @@ context 'when association is not loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be true - end - - expect_query(1) do - expect(person.posts.exists?).to be true - end + expect_query(1) { expect(person.posts.exists?).to be true } + expect_query(1) { expect(person.posts.exists?).to be true } end end context 'when association is loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be true - end - + expect_query(1) { expect(person.posts.exists?).to be true } person.posts.to_a - - expect_query(1) do - expect(person.posts.exists?).to be true - end + expect_query(1) { expect(person.posts.exists?).to be true } end end end context 'when documents exist in application but not in database' do - before do person.posts.build end @@ -2418,109 +1937,67 @@ context 'when association is not loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be false - end - - expect_query(1) do - expect(person.posts.exists?).to be false - end + expect_query(1) { expect(person.posts.exists?).to be false } + expect_query(1) { expect(person.posts.exists?).to be false } end end context 'when association is loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be false - end - + expect_query(1) { expect(person.posts.exists?).to be false } person.posts.to_a - - expect_query(1) do - expect(person.posts.exists?).to be false - end + expect_query(1) { expect(person.posts.exists?).to be false } end end end context 'when no documents exist' do - it 'returns false' do expect(person.posts.exists?).to be false end context 'when association is not loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be false - end - - expect_query(1) do - expect(person.posts.exists?).to be false - end + expect_query(1) { expect(person.posts.exists?).to be false } + expect_query(1) { expect(person.posts.exists?).to be false } end end context 'when association is loaded' do it 'queries database on each call' do - expect_query(1) do - expect(person.posts.exists?).to be false - end + expect_query(1) { expect(person.posts.exists?).to be false } person.posts.to_a - expect_query(1) do - expect(person.posts.exists?).to be false - end + expect_query(1) { expect(person.posts.exists?).to be false } end end end end describe '#find' do - context 'when iterating after the find' do - - let(:person) do - Person.create! - end - - let(:post_id) do - person.posts.first.id - end + let(:person) { Person.create! } + let(:post_id) { person.posts.first.id } before do 5.times { person.posts.create! } end it 'does not change the in memory size' do - expect do - person.posts.find(post_id) - end.to_not change { person.posts.to_a.size } + expect { person.posts.find(post_id) } + .to_not(change { person.posts.to_a.size }) end end context 'when the association is not polymorphic' do - - let(:person) do - Person.create! - end - - let!(:post_one) do - person.posts.create!(title: 'Test') - end - - let!(:post_two) do - person.posts.create!(title: 'OMG I has associations') - end + let(:person) { Person.create! } + let!(:post_one) { person.posts.create!(title: 'Test') } + let!(:post_two) { person.posts.create!(title: 'OMG I has associations') } context 'when providing an id' do - context 'when the id matches' do - - let(:post) do - person.posts.find(post_one.id) - end + let(:post) { person.posts.find(post_one.id) } it 'returns the matching document' do expect(post).to eq(post_one) @@ -2528,36 +2005,30 @@ end context 'when the id matches but is not scoped to the association' do - - let(:post) do - Post.create!(title: 'Unscoped') - end + let(:post) { Post.create!(title: 'Unscoped') } it 'raises an error' do - expect do - person.posts.find(post.id) - end.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/) + expect { person.posts.find(post.id) } + .to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Post with id\(s\)/) end end context 'when the id does not match' do - context 'when config set to raise error' do config_override :raise_not_found_error, true it 'raises an error' do - expect do - person.posts.find(BSON::ObjectId.new) - end.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/) + expect { person.posts.find(BSON::ObjectId.new) } + .to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Post with id\(s\)/) end end context 'when config set not to raise error' do config_override :raise_not_found_error, false - let(:post) do - person.posts.find(BSON::ObjectId.new) - end + let(:post) { person.posts.find(BSON::ObjectId.new) } it 'returns nil' do expect(post).to be_nil @@ -2567,12 +2038,8 @@ end context 'when providing an array of ids' do - context 'when the ids match' do - - let(:posts) do - person.posts.find([post_one.id, post_two.id]) - end + let(:posts) { person.posts.find([post_one.id, post_two.id]) } it 'returns the matching documents' do expect(posts).to eq([post_one, post_two]) @@ -2580,23 +2047,20 @@ end context 'when the ids do not match' do - context 'when config set to raise error' do config_override :raise_not_found_error, true it 'raises an error' do - expect do - person.posts.find([BSON::ObjectId.new]) - end.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Post with id\(s\)/) + expect { person.posts.find([BSON::ObjectId.new]) } + .to raise_error(Mongoid::Errors::DocumentNotFound, + /Document\(s\) not found for class Post with id\(s\)/) end end context 'when config set not to raise error' do config_override :raise_not_found_error, false - let(:posts) do - person.posts.find([BSON::ObjectId.new]) - end + let(:posts) { person.posts.find([BSON::ObjectId.new]) } it 'returns an empty array' do expect(posts).to be_empty @@ -2607,26 +2071,13 @@ end context 'when the association is polymorphic' do - - let(:movie) do - Movie.create! - end - - let!(:rating_one) do - movie.ratings.create!(value: 1) - end - - let!(:rating_two) do - movie.ratings.create!(value: 5) - end + let(:movie) { Movie.create! } + let!(:rating_one) { movie.ratings.create!(value: 1) } + let!(:rating_two) { movie.ratings.create!(value: 5) } context 'when providing an id' do - context 'when the id matches' do - - let(:rating) do - movie.ratings.find(rating_one.id) - end + let(:rating) { movie.ratings.find(rating_one.id) } it 'returns the matching document' do expect(rating).to eq(rating_one) @@ -2634,23 +2085,24 @@ end context 'when the id does not match' do - context 'when config set to raise error' do config_override :raise_not_found_error, true + let(:expected_error) { Mongoid::Errors::DocumentNotFound } + let(:expected_message) do + /Document\(s\) not found for class Rating with id\(s\)/ + end + it 'raises an error' do - expect do - movie.ratings.find(BSON::ObjectId.new) - end.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Rating with id\(s\)/) + expect { movie.ratings.find(BSON::ObjectId.new) } + .to raise_error(expected_error, expected_message) end end context 'when config set not to raise error' do config_override :raise_not_found_error, false - let(:rating) do - movie.ratings.find(BSON::ObjectId.new) - end + let(:rating) { movie.ratings.find(BSON::ObjectId.new) } it 'returns nil' do expect(rating).to be_nil @@ -2660,12 +2112,8 @@ end context 'when providing an array of ids' do - context 'when the ids match' do - - let(:ratings) do - movie.ratings.find([rating_one.id, rating_two.id]) - end + let(:ratings) { movie.ratings.find([rating_one.id, rating_two.id]) } it 'returns the first matching document' do expect(ratings).to include(rating_one) @@ -2681,23 +2129,24 @@ end context 'when the ids do not match' do - context 'when config set to raise error' do config_override :raise_not_found_error, true + let(:expected_error) { Mongoid::Errors::DocumentNotFound } + let(:expected_message) do + /Document\(s\) not found for class Rating with id\(s\)/ + end + it 'raises an error' do - expect do - movie.ratings.find([BSON::ObjectId.new]) - end.to raise_error(Mongoid::Errors::DocumentNotFound, /Document\(s\) not found for class Rating with id\(s\)/) + expect { movie.ratings.find([BSON::ObjectId.new]) } + .to raise_error(expected_error, expected_message) end end context 'when config set not to raise error' do config_override :raise_not_found_error, false - let(:ratings) do - movie.ratings.find([BSON::ObjectId.new]) - end + let(:ratings) { movie.ratings.find([BSON::ObjectId.new]) } it 'returns an empty array' do expect(ratings).to be_empty @@ -2708,22 +2157,16 @@ end context 'with block' do - let!(:author) do - Person.create!(title: 'Person') - end + let(:titles) { ['post one', 'post two'] } + let!(:author) { Person.create!(title: 'Person') } + let!(:post_one) { author.posts.create!(title: titles[0]) } - let!(:post_one) do - author.posts.create!(title: 'post one') - end - - let!(:post_two) do - author.posts.create!(title: 'post two') - end + before { author.posts.create!(title: titles[1]) } it 'finds one' do expect( author.posts.find do |post| - post.title == 'post one' + post.title == titles[0] end ).to be_a(Post) end @@ -2731,7 +2174,7 @@ it 'returns first match of multiple' do expect( author.posts.find do |post| - ['post one', 'post two'].include?(post.title) + titles.include?(post.title) end ).to eq(post_one) end @@ -2739,7 +2182,7 @@ it 'returns nil when not found' do expect( author.posts.find do |post| - post.title == 'non exiting one' + post.title == 'non existing one' end ).to be_nil end @@ -2747,22 +2190,12 @@ end describe '#find_or_create_by' do - context 'when the association is not polymorphic' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(title: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Testing') } context 'when the document exists' do - - let(:found) do - person.posts.find_or_create_by(title: 'Testing') - end + let(:found) { person.posts.find_or_create_by(title: 'Testing') } it 'returns the document' do expect(found).to eq(post) @@ -2774,9 +2207,7 @@ end context 'when the document does not exist' do - context 'when there is no criteria attached' do - let(:found) do person.posts.find_or_create_by(title: 'Test') do |post| post.content = 'The Content' @@ -2801,10 +2232,7 @@ end context 'when a criteria is attached' do - - let(:found) do - person.posts.recent.find_or_create_by(title: 'Test') - end + let(:found) { person.posts.recent.find_or_create_by(title: 'Test') } it 'sets the new document attributes' do expect(found.title).to eq('Test') @@ -2822,20 +2250,11 @@ end context 'when the association is polymorphic' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.create!(value: 1) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.create!(value: 1) } context 'when the document exists' do - - let(:found) do - movie.ratings.find_or_create_by(value: 1) - end + let(:found) { movie.ratings.find_or_create_by(value: 1) } it 'returns the document' do expect(found).to eq(rating) @@ -2847,10 +2266,7 @@ end context 'when the document does not exist' do - - let(:found) do - movie.ratings.find_or_create_by(value: 3) - end + let(:found) { movie.ratings.find_or_create_by(value: 3) } it 'sets the new document attributes' do expect(found.value).to eq(3) @@ -2868,22 +2284,12 @@ end describe '#find_or_create_by!' do - context 'when the association is not polymorphic' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(title: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Testing') } context 'when the document exists' do - - let(:found) do - person.posts.find_or_create_by!(title: 'Testing') - end + let(:found) { person.posts.find_or_create_by!(title: 'Testing') } it 'returns the document' do expect(found).to eq(post) @@ -2895,9 +2301,7 @@ end context 'when the document does not exist' do - context 'when there is no criteria attached' do - let(:found) do person.posts.find_or_create_by!(title: 'Test') do |post| post.content = 'The Content' @@ -2922,10 +2326,7 @@ end context 'when a criteria is attached' do - - let(:found) do - person.posts.recent.find_or_create_by!(title: 'Test') - end + let(:found) { person.posts.recent.find_or_create_by!(title: 'Test') } it 'sets the new document attributes' do expect(found.title).to eq('Test') @@ -2943,20 +2344,11 @@ end context 'when the association is polymorphic' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.create!(value: 1) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.create!(value: 1) } context 'when the document exists' do - - let(:found) do - movie.ratings.find_or_create_by!(value: 1) - end + let(:found) { movie.ratings.find_or_create_by!(value: 1) } it 'returns the document' do expect(found).to eq(rating) @@ -2968,10 +2360,7 @@ end context 'when the document does not exist' do - - let(:found) do - movie.ratings.find_or_create_by!(value: 3) - end + let(:found) { movie.ratings.find_or_create_by!(value: 3) } it 'sets the new document attributes' do expect(found.value).to eq(3) @@ -2986,11 +2375,9 @@ end context 'when validation fails' do - it 'raises an error' do - expect do - movie.comments.find_or_create_by!(title: '') - end.to raise_error(Mongoid::Errors::Validations) + expect { movie.comments.find_or_create_by!(title: '') } + .to raise_error(Mongoid::Errors::Validations) end end end @@ -2998,22 +2385,12 @@ end describe '#find_or_initialize_by' do - context 'when the association is not polymorphic' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(title: 'Testing') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'Testing') } context 'when the document exists' do - - let(:found) do - person.posts.find_or_initialize_by(title: 'Testing') - end + let(:found) { person.posts.find_or_initialize_by(title: 'Testing') } it 'returns the document' do expect(found).to eq(post) @@ -3021,7 +2398,6 @@ end context 'when the document does not exist' do - let(:found) do person.posts.find_or_initialize_by(title: 'Test') do |post| post.content = 'The Content' @@ -3043,20 +2419,11 @@ end context 'when the association is polymorphic' do - - let(:movie) do - Movie.create! - end - - let!(:rating) do - movie.ratings.create!(value: 1) - end + let(:movie) { Movie.create! } + let!(:rating) { movie.ratings.create!(value: 1) } context 'when the document exists' do - - let(:found) do - movie.ratings.find_or_initialize_by(value: 1) - end + let(:found) { movie.ratings.find_or_initialize_by(value: 1) } it 'returns the document' do expect(found).to eq(rating) @@ -3064,10 +2431,7 @@ end context 'when the document does not exist' do - - let(:found) do - movie.ratings.find_or_initialize_by(value: 3) - end + let(:found) { movie.ratings.find_or_initialize_by(value: 3) } it 'sets the new document attributes' do expect(found.value).to eq(3) @@ -3081,25 +2445,17 @@ end describe '#initialize' do - context 'when an illegal mixed association exists' do - - let(:post) do - Post.new - end + let(:post) { Post.new } it 'raises an error' do - expect do - post.videos - end.to raise_error(Mongoid::Errors::MixedRelations) + expect { post.videos } + .to raise_error(Mongoid::Errors::MixedRelations) end end context 'when a cyclic association exists' do - - let(:post) do - Post.new - end + let(:post) { Post.new } it 'does not raise an error' do expect(post.roles).to be_empty @@ -3108,23 +2464,14 @@ end describe '#last' do + let(:person) { Person.create! } - let(:person) do - Person.create! - end - - let!(:persisted_post) do - person.posts.create! - end + before { person.posts.create! } context 'when a new document is added' do - - let!(:new_post) do - person.posts.new - end + let!(:new_post) { person.posts.new } context 'when the target is subsequently loaded' do - before do person.posts.entries end @@ -3137,21 +2484,17 @@ end describe '#max' do + let(:person) { Person.create! } + let(:post_one) { Post.create!(rating: 5) } + let(:post_two) { Post.create!(rating: 10) } - let(:person) do - Person.create! - end + # rubocop:disable Performance/CompareWithBlock let(:max) do - person.posts.max_by(&:rating) - end - - let(:post_one) do - Post.create!(rating: 5) - end - - let(:post_two) do - Post.create!(rating: 10) + person.posts.max do |a, b| + a.rating <=> b.rating + end end + # rubocop:enable Performance/CompareWithBlock before do person.posts.push(post_one, post_two) @@ -3163,21 +2506,10 @@ end describe '#max_by' do - - let(:person) do - Person.create! - end - let(:max) do - person.posts.max_by(&:rating) - end - - let(:post_one) do - Post.create!(rating: 5) - end - - let(:post_two) do - Post.create!(rating: 10) - end + let(:person) { Person.create! } + let(:post_one) { Post.create!(rating: 5) } + let(:post_two) { Post.create!(rating: 10) } + let(:max) { person.posts.max_by(&:rating) } before do person.posts.push(post_one, post_two) @@ -3189,34 +2521,24 @@ end describe '#method_missing' do + let!(:person) { Person.create! } + let!(:post_one) { person.posts.create!(title: 'First', content: 'Posting') } - let!(:person) do - Person.create! - end - - let!(:post_one) do - person.posts.create!(title: 'First', content: 'Posting') - end - - let!(:post_two) do + before do person.posts.create!(title: 'Second', content: 'Testing') end context 'when providing a single criteria' do - - let(:posts) do - person.posts.where(title: 'First') - end + let(:posts) { person.posts.where(title: 'First') } it 'applies the criteria to the documents' do expect(posts).to eq([post_one]) end context 'when providing a collation' do + min_server_version '3.4' - let(:posts) do - person.posts.where(title: 'FIRST').collation(locale: 'en_US', strength: 2) - end + let(:posts) { person.posts.where(title: 'FIRST').collation(locale: 'en_US', strength: 2) } it 'applies the collation option to the query' do expect(posts).to eq([post_one]) @@ -3225,10 +2547,7 @@ end context 'when providing a criteria class method' do - - let(:posts) do - person.posts.posting - end + let(:posts) { person.posts.posting } it 'applies the criteria to the documents' do expect(posts).to eq([post_one]) @@ -3236,10 +2555,7 @@ end context 'when chaining criteria' do - - let(:posts) do - person.posts.posting.where(:title.in => ['First']) - end + let(:posts) { person.posts.posting.where(:title.in => ['First']) } it 'applies the criteria to the documents' do expect(posts).to eq([post_one]) @@ -3247,12 +2563,8 @@ end context 'when delegating methods' do - describe '#distinct' do - - let(:values) do - person.posts.distinct(:title) - end + let(:values) { person.posts.distinct(:title) } it 'returns the distinct values for the fields' do expect(values).to include('First') @@ -3262,56 +2574,18 @@ end end - describe '#respond_to_missing?' do - let!(:person) { Person.create! } - - let!(:post_one) do - person.posts.create!(title: 'First', content: 'Posting') - end - - let!(:post_two) do - person.posts.create!(title: 'Second', content: 'Testing') - end - - let(:posts) { person.posts } - - context 'when target or criteria respond to the method' do - it 'returns true for target method' do - expect(posts.respond_to?(:count)).to be true - end - - it 'returns true for criteria method' do - expect(posts.respond_to?(:where)).to be true - end - - it 'returns true for criteria class method' do - expect(posts.respond_to?(:posting)).to be true - end - end - - context 'when neither target nor criteria respond to the method' do - it 'returns false' do - expect(posts.respond_to?(:non_existent_method)).to be false - end - end - end - describe '#min' do + let(:person) { Person.create! } + let(:post_one) { Post.create!(rating: 5) } + let(:post_two) { Post.create!(rating: 10) } - let(:person) do - Person.create! - end + # rubocop:disable Performance/CompareWithBlock let(:min) do - person.posts.min_by(&:rating) - end - - let(:post_one) do - Post.create!(rating: 5) - end - - let(:post_two) do - Post.create!(rating: 10) + person.posts.min do |a, b| + a.rating <=> b.rating + end end + # rubocop:enable Performance/CompareWithBlock before do person.posts.push(post_one, post_two) @@ -3323,21 +2597,11 @@ end describe '#min_by' do + let(:person) { Person.create! } + let(:post_one) { Post.create!(rating: 5) } - let(:person) do - Person.create! - end - let(:min) do - person.posts.min_by(&:rating) - end - - let(:post_one) do - Post.create!(rating: 5) - end - - let(:post_two) do - Post.create!(rating: 10) - end + let(:post_two) { Post.create!(rating: 10) } + let(:min) { person.posts.min_by(&:rating) } before do person.posts.push(post_one, post_two) @@ -3349,26 +2613,13 @@ end describe '#nullify_all' do - context 'when the inverse has not been loaded' do + let(:person) { Person.create! } + let(:from_db) { Person.first } - let(:person) do - Person.create! - end - - let!(:post_one) do + before do person.posts.create!(title: 'One') - end - - let!(:post_two) do person.posts.create!(title: 'Two') - end - - let(:from_db) do - Person.first - end - - before do from_db.posts.nullify_all end @@ -3388,18 +2639,9 @@ end context 'when the association is not polymorphic' do - - let(:person) do - Person.create! - end - - let!(:post_one) do - person.posts.create!(title: 'One') - end - - let!(:post_two) do - person.posts.create!(title: 'Two') - end + let(:person) { Person.create! } + let!(:post_one) { person.posts.create!(title: 'One') } + let!(:post_two) { person.posts.create!(title: 'Two') } before do person.posts.nullify_all @@ -3422,7 +2664,6 @@ end context 'when adding a nullified document back to the association' do - before do person.posts.push(post_one) end @@ -3434,18 +2675,9 @@ end context 'when the association is polymorphic' do - - let(:movie) do - Movie.create!(title: 'Oldboy') - end - - let!(:rating_one) do - movie.ratings.create!(value: 10) - end - - let!(:rating_two) do - movie.ratings.create!(value: 9) - end + let(:movie) { Movie.create!(title: 'Oldboy') } + let!(:rating_one) { movie.ratings.create!(value: 10) } + let!(:rating_two) { movie.ratings.create!(value: 9) } before do movie.ratings.nullify_all @@ -3466,19 +2698,11 @@ end describe '#respond_to?' do - - let(:person) do - Person.new - end - - let(:posts) do - person.posts - end + let(:person) { Person.new } + let(:posts) { person.posts } Array.public_instance_methods.each do |method| - context "when checking #{method}" do - it 'returns true' do expect(posts.respond_to?(method)).to be true end @@ -3486,9 +2710,7 @@ end described_class.public_instance_methods.each do |method| - context "when checking #{method}" do - it 'returns true' do expect(posts.respond_to?(method)).to be true end @@ -3496,9 +2718,7 @@ end Post.scopes.each_key do |method| - context "when checking #{method}" do - it 'returns true' do expect(posts.respond_to?(method)).to be true end @@ -3507,14 +2727,8 @@ end describe '#scoped' do - - let(:person) do - Person.new - end - - let(:scoped) do - person.posts.scoped - end + let(:person) { Person.new } + let(:scoped) { person.posts.scoped } it 'returns the association criteria' do expect(scoped).to be_a(Mongoid::Criteria) @@ -3526,16 +2740,11 @@ end %i[size length].each do |method| - describe "##{method}" do - - let(:movie) do - Movie.create! - end + let(:movie) { Movie.create! } context 'when documents have been persisted' do - - let!(:rating) do + before do movie.ratings.create!(value: 1) end @@ -3545,7 +2754,6 @@ end context 'when documents have not been persisted' do - before do movie.ratings.build(value: 1) movie.ratings.create!(value: 2) @@ -3559,24 +2767,12 @@ end describe '#unscoped' do - context 'when the association has no default scope' do + before { Post.create!(title: 'unattributed') } - let!(:person) do - Person.create! - end - - let!(:post_one) do - person.posts.create!(title: 'first') - end - - let!(:post_two) do - Post.create!(title: 'second') - end - - let(:unscoped) do - person.posts.unscoped - end + let!(:person) { Person.create! } + let!(:post_one) { person.posts.create!(title: 'first') } + let(:unscoped) { person.posts.unscoped } it 'returns only the associated documents' do expect(unscoped).to eq([post_one]) @@ -3584,22 +2780,11 @@ end context 'when the association has a default scope' do + before { Acolyte.create!(name: 'unaffiliated') } - let!(:church) do - Church.create! - end - - let!(:acolyte_one) do - church.acolytes.create!(name: 'first') - end - - let!(:acolyte_two) do - Acolyte.create!(name: 'second') - end - - let(:unscoped) do - church.acolytes.unscoped - end + let!(:church) { Church.create! } + let!(:acolyte_one) { church.acolytes.create!(name: 'first') } + let(:unscoped) { church.acolytes.unscoped } it 'only returns associated documents' do expect(unscoped).to eq([acolyte_one]) @@ -3612,22 +2797,11 @@ end context 'when the association has an order defined' do + let(:person) { Person.create! } + let(:post_one) { OrderedPost.create!(rating: 10, title: '1') } - let(:person) do - Person.create! - end - - let(:post_one) do - OrderedPost.create!(rating: 10, title: '1') - end - - let(:post_two) do - OrderedPost.create!(rating: 20, title: '2') - end - - let(:post_three) do - OrderedPost.create!(rating: 20, title: '3') - end + let(:post_two) { OrderedPost.create!(rating: 20, title: '2') } + let(:post_three) { OrderedPost.create!(rating: 20, title: '3') } before do person.ordered_posts.nullify_all @@ -3635,46 +2809,33 @@ end it 'order documents' do - expect(person.ordered_posts(true)).to eq( - [post_two, post_three, post_one] - ) + expect(person.ordered_posts(true)) + .to eq [post_two, post_three, post_one] end it 'chaining order criteria' do - expect(person.ordered_posts.order_by(:title.desc).to_a).to eq( - [post_three, post_two, post_one] - ) + expect(person.ordered_posts.order_by(:title.desc).to_a) + .to eq [post_three, post_two, post_one] end end context 'when reloading the association' do + let!(:person) { Person.create! } + let!(:post_one) { Post.create!(title: 'one') } - let!(:person) do - Person.create! - end - - let!(:post_one) do - Post.create!(title: 'one') - end - - let!(:post_two) do - Post.create!(title: 'two') - end + let!(:post_two) { Post.create!(title: 'two') } before do person.posts << post_one end context 'when the association references the same documents' do - before do Post.collection.find({ _id: post_one.id }) .update_one({ '$set' => { title: 'reloaded' } }) end - let(:reloaded) do - person.posts(true) - end + let(:reloaded) { person.posts(true) } it 'reloads the document from the database' do expect(reloaded.first.title).to eq('reloaded') @@ -3682,14 +2843,11 @@ end context 'when the association references different documents' do - before do person.posts << post_two end - let(:reloaded) do - person.posts(true) - end + let(:reloaded) { person.posts(true) } it 'reloads the first document from the database' do expect(reloaded).to include(post_one) @@ -3702,7 +2860,6 @@ end context 'when the parent is using integer ids' do - let(:jar) do Jar.create! do |doc| doc._id = 1 @@ -3715,28 +2872,13 @@ end context 'when adding a document' do - - let(:person) do - Person.new - end - - let(:post_one) do - Post.new - end - - let(:first_add) do - person.posts.push(post_one) - end + let(:person) { Person.new } + let(:post_one) { Post.new } + let(:first_add) { person.posts.push(post_one) } context 'when chaining a second add' do - - let(:post_two) do - Post.new - end - - let(:result) do - first_add.push(post_two) - end + let(:post_two) { Post.new } + let(:result) { first_add.push(post_two) } it 'adds both documents' do expect(result).to eq([post_one, post_two]) @@ -3745,17 +2887,10 @@ end context 'when pushing with a before_add callback' do - - let(:artist) do - Artist.new - end - - let(:album) do - Album.new - end + let(:artist) { Artist.new } + let(:album) { Album.new } context 'when execution raises no errors' do - before do artist.albums << album end @@ -3774,30 +2909,20 @@ end context 'when execution raises errors' do - before do - expect(artist).to receive(:before_add_album).and_raise - begin - artist.albums << album - rescue StandardError - end + allow(artist).to receive(:before_add_album).and_raise end it 'does not add the document to the association' do + expect { artist.albums << album }.to raise_error(StandardError) expect(artist.albums).to be_empty end end end context 'when pushing with an after_add callback' do - - let(:artist) do - Artist.new - end - - let(:album) do - Album.new - end + let(:artist) { Artist.new } + let(:album) { Album.new } it 'executes the callback' do artist.albums << album @@ -3805,57 +2930,48 @@ end context 'when execution raises errors' do - before do - expect(artist).to receive(:after_add_album).and_raise - begin - artist.albums << album - rescue StandardError - end + allow(artist).to receive(:after_add_album).and_raise end it 'adds the document to the association' do + expect { artist.albums << album }.to raise_error(StandardError) expect(artist.albums).to eq([album]) end end context 'when the association already exists' do - before do artist.albums << album album.save! artist.save! - expect(artist).to_not receive(:after_add_album) end let(:reloaded_album) do - Album.where(artist_id: artist.id).first + Album.where(artist_id: artist.id).first.tap do |a| + allow(a.artist).to receive(:after_add_album) + end end + let(:reloaded_album_artist) { reloaded_album.artist } + it 'does not execute the callback when the association is accessed' do - expect(reloaded_album.artist.after_add_referenced_called).to be_nil + expect(reloaded_album_artist).to_not receive(:after_add_album) + expect(reloaded_album_artist.after_add_referenced_called).to be_nil end end end context 'when #delete or #clear with before_remove callback' do - - let(:artist) do - Artist.new - end - - let(:album) do - Album.new - end + let(:artist) { Artist.new } + let(:album) { Album.new } before do artist.albums << album end context 'when executing raises no errors' do - describe '#delete' do - before do artist.albums.delete album end @@ -3870,7 +2986,6 @@ end describe '#clear' do - before do artist.albums.clear end @@ -3885,31 +3000,20 @@ end context 'when execution raises errors' do - before do - expect(artist).to receive(:before_remove_album).and_raise + allow(artist).to receive(:before_remove_album).and_raise end describe '#delete' do - - before do - artist.albums.delete(album) - rescue StandardError - end - it 'does not remove the document from the association' do + expect { artist.albums.delete(album) }.to raise_error(StandardError) expect(artist.albums).to eq([album]) end end describe '#clear' do - - before do - artist.albums.clear - rescue StandardError - end - it 'does not clear the association' do + expect { artist.albums.clear }.to raise_error(StandardError) expect(artist.albums).to eq([album]) end end @@ -3918,71 +3022,44 @@ end context 'when #delete or #clear with after_remove callback' do - - let(:artist) do - Artist.new - end - - let(:album) do - Album.new - end + let(:artist) { Artist.new } + let(:album) { Album.new } before do artist.albums << album end context 'without errors' do - describe '#delete' do - - before do - artist.albums.delete album - end - it 'executes the callback' do + expect { artist.albums.delete album }.to_not raise_error expect(artist.after_remove_referenced_called).to be true end end describe '#clear' do - - before do - artist.albums.clear - end - it 'executes the callback' do - artist.albums.clear + expect { artist.albums.clear }.to_not raise_error expect(artist.after_remove_referenced_called).to be true end end end context 'when errors are raised' do - before do - expect(artist).to receive(:after_remove_album).and_raise + allow(artist).to receive(:after_remove_album).and_raise end describe '#delete' do - - before do - artist.albums.delete(album) - rescue StandardError - end - it 'removes the documents from the association' do + expect { artist.albums.delete(album) }.to raise_error(StandardError) expect(artist.albums).to be_empty end end describe '#clear' do - - before do - artist.albums.clear - rescue StandardError - end - it 'removes the documents from the association' do + expect { artist.albums.clear }.to raise_error(StandardError) expect(artist.albums).to be_empty end end @@ -3990,22 +3067,11 @@ end context 'when executing a criteria call on an ordered association' do + let(:person) { Person.create! } + let!(:post_one) { person.ordered_posts.create!(rating: 1) } - let(:person) do - Person.create! - end - - let!(:post_one) do - person.ordered_posts.create!(rating: 1) - end - - let!(:post_two) do - person.ordered_posts.create!(rating: 5) - end - - let(:criteria) do - person.ordered_posts.only(:_id, :rating) - end + let!(:post_two) { person.ordered_posts.create!(rating: 5) } + let(:criteria) { person.ordered_posts.only(:_id, :rating) } it 'does not drop the ordering' do expect(criteria).to eq([post_two, post_one]) @@ -4013,14 +3079,8 @@ end context 'when accessing a scope named open' do - - let(:person) do - Person.create! - end - - let!(:post) do - person.posts.create!(title: 'open') - end + let(:person) { Person.create! } + let!(:post) { person.posts.create!(title: 'open') } it 'returns the appropriate documents' do expect(person.posts.open).to eq([post]) @@ -4028,21 +3088,16 @@ end context 'when accessing a association named parent' do - - let!(:parent) do - Odd.create!(name: 'odd parent') - end - + let!(:parent) { Odd.create!(name: 'odd parent') } let(:child) do - Even.create!(parent_id: parent.id, name: 'original even child') + Even + .create!(parent_id: parent.id, name: 'original even child') + .tap(&:parent) # preload the parent association end - it 'updates the child after accessing the parent' do - # Access parent association on the child to make sure it is loaded - child.parent - - new_child_name = 'updated even child' + let(:new_child_name) { 'updated even child' } + it 'updates the child after accessing the parent' do child.name = new_child_name child.save! @@ -4052,18 +3107,10 @@ end context 'when a document has referenced and embedded associations' do + let(:agent) { Agent.new } + let(:basic) { Basic.new } - let(:agent) do - Agent.new - end - - let(:basic) do - Basic.new - end - - let(:address) do - Address.new - end + let(:address) { Address.new } before do agent.basics << basic @@ -4076,14 +3123,8 @@ end context 'when the two models use the same name to refer to the association' do - - let(:agent) do - Agent.new - end - - let(:band) do - Band.new - end + let(:agent) { Agent.new } + let(:band) { Band.new } before do agent.same_name = band @@ -4093,32 +3134,37 @@ end it 'constructs the correct criteria' do - expect(band.same_name).to eq([agent]) + expect(band.same_name).to eq [agent] end end - context 'when removing a document with counter_cache on' do + context 'when updating a document with counter_cache on' do let(:post) { Post.create! } - let(:person1) { Person.create! } - let(:person2) { Person.create! } + let(:arthur) { Person.create! } + let(:betty) { Person.create! } - before do - post.update_attribute(:person, person1) - expect(person1.posts_count).to eq 1 - - person2 - post.update_attribute(:person, person2) - person1.reload - expect(person1.posts_count).to eq 0 - expect(person2.posts_count).to eq 1 - - post.update_attribute(:person, nil) - person1.reload - person2.reload + context 'when setting an attribution' do + it 'sets the counter correctly' do + post.update_attribute(:person, arthur) + expect(arthur.reload.posts_count).to eq 1 + end + end + + context 'when changing an attribution' do + it 'sets the counter correctly' do + post.update_attribute(:person, arthur) + post.update_attribute(:person, betty) + expect(arthur.reload.posts_count).to eq 0 + expect(betty.reload.posts_count).to eq 1 + end end - it 'the count field is updated' do - expect(person2.posts_count).to eq 0 + context 'when removing an attribution' do + it 'sets the counter correctly' do + post.update_attribute(:person, arthur) + post.update_attribute(:person, nil) + expect(arthur.reload.posts_count).to eq 0 + end end end @@ -4134,17 +3180,14 @@ before do Agent.create! Basic.create! + agent.basic_ids.push basic.id end let!(:agent) { Agent.first } let!(:basic) { Basic.first } - before do - agent.basic_ids.push(basic.id) - end - it 'works on the first attempt' do - expect(agent.basic_ids).to eq([basic.id]) + expect(agent.basic_ids).to eq [basic.id] end end end diff --git a/spec/mongoid/attributes_spec.rb b/spec/mongoid/attributes_spec.rb index 67c1b7aa5..a45e986ea 100644 --- a/spec/mongoid/attributes_spec.rb +++ b/spec/mongoid/attributes_spec.rb @@ -2506,7 +2506,7 @@ end end - context 'when doing delete_one' do + context 'when doing _remove' do let(:doc) { NestedBook.create! } let(:page) { NestedPage.new } @@ -2515,7 +2515,7 @@ doc.pages << NestedPage.new doc.pages << NestedPage.new - doc.pages.send(:delete_one, page) + doc.pages._remove(page) end it 'updates the attributes' do @@ -2727,4 +2727,31 @@ expect(catalog.set_field).to eq(Set.new([1, 2])) end end + + context 'when an embedded field has a capitalized store_as name' do + let(:person) { Person.new(Purse: { brand: 'Gucci' }) } + + it 'sets the value' do + expect(person.purse.brand).to eq('Gucci') + end + + it 'saves successfully' do + expect(person.save!).to be(true) + end + + context 'when persisted' do + before do + person.save! + person.reload + end + + it 'persists the value' do + expect(person.reload.purse.brand).to eq('Gucci') + end + + it 'uses the correct key in the database' do + expect(person.collection.find(_id: person.id).first['Purse']['_id']).to eq(person.purse.id) + end + end + end end diff --git a/spec/mongoid/config/encryption_spec.rb b/spec/mongoid/config/encryption_spec.rb index 7a7de3b22..3d63ef6ea 100644 --- a/spec/mongoid/config/encryption_spec.rb +++ b/spec/mongoid/config/encryption_spec.rb @@ -29,6 +29,7 @@ }, 'blood_type' => { 'encrypt' => { + 'keyId' => '/blood_type_key_name', 'bsonType' => 'string', 'algorithm' => 'AEAD_AES_256_CBC_HMAC_SHA_512-Random' } @@ -80,7 +81,7 @@ let(:expected_schema_map) do { - 'mongoid_test.crypt_cars' => { + 'vehicles.crypt_cars' => { 'bsonType' => 'object', 'encryptMetadata' => { 'keyId' => [BSON::Binary.new(Base64.decode64('grolrnFVSSW9Gq04Q87R9Q=='), :uuid)], diff --git a/spec/mongoid/config_spec.rb b/spec/mongoid/config_spec.rb index bbebb00ff..9f2e12450 100644 --- a/spec/mongoid/config_spec.rb +++ b/spec/mongoid/config_spec.rb @@ -311,6 +311,15 @@ it_behaves_like 'a config option' end + context 'when setting the allow_bson5_decimal128 option in the config' do + min_bson_version '5.0' + + let(:option) { :allow_bson5_decimal128 } + let(:default) { false } + + it_behaves_like 'a config option' + end + context 'when setting the legacy_readonly option in the config' do let(:option) { :legacy_readonly } let(:default) { false } diff --git a/spec/mongoid/contextual/mongo_spec.rb b/spec/mongoid/contextual/mongo_spec.rb index 2a45774d7..fb9e7a949 100644 --- a/spec/mongoid/contextual/mongo_spec.rb +++ b/spec/mongoid/contextual/mongo_spec.rb @@ -3682,6 +3682,20 @@ end end + context 'when using aliased field names' do + before do + context.update_all('$set' => { years: 100 }) + end + + it 'updates the first matching document' do + expect(depeche_mode.reload.years).to eq(100) + end + + it 'updates the last matching document' do + expect(new_order.reload.years).to eq(100) + end + end + context 'when the attributes must be mongoized' do before do diff --git a/spec/mongoid/criteria/queryable/selector_spec.rb b/spec/mongoid/criteria/queryable/selector_spec.rb index dba4a80da..9e54f9d4c 100644 --- a/spec/mongoid/criteria/queryable/selector_spec.rb +++ b/spec/mongoid/criteria/queryable/selector_spec.rb @@ -44,7 +44,7 @@ end end - context 'when selector contains a $nin' do + context 'when selector contains a $nin string' do let(:initial) do { '$nin' => ['foo'] } @@ -72,7 +72,35 @@ end end - context 'when selector contains a $in' do + context 'when selector contains a $nin symbol' do + + let(:initial) do + { :$nin => %w[foo] } + end + + before do + selector['field'] = initial + end + + context 'when merging in a new $nin' do + + let(:other) do + { 'field' => { :$nin => %w[bar] } } + end + + before do + selector.merge!(other) + end + + it 'combines the two $nin queries into one' do + expect(selector).to eq({ + 'field' => { :$nin => %w[foo bar] } + }) + end + end + end + + context 'when selector contains a $in string' do let(:initial) do { '$in' => [1, 2] } @@ -117,6 +145,51 @@ end end + context 'when selector contains a $in symbol' do + + let(:initial) do + { :$in => [1, 2] } + end + + before do + selector['field'] = initial + end + + context 'when merging in a new $in with an intersecting value' do + + let(:other) do + { 'field' => { :$in => [1] } } + end + + before do + selector.merge!(other) + end + + it 'intersects the $in values' do + expect(selector).to eq({ + 'field' => { :$in => [1] } + }) + end + end + + context 'when merging in a new $in with no intersecting values' do + + let(:other) do + { 'field' => { :$in => [3] } } + end + + before do + selector.merge!(other) + end + + it 'intersects the $in values' do + expect(selector).to eq({ + 'field' => { :$in => [] } + }) + end + end + end + context 'when selector is not nested' do before do diff --git a/spec/mongoid/criteria/queryable/storable_spec.rb b/spec/mongoid/criteria/queryable/storable_spec.rb index 9fc9ecbd7..89b008f01 100644 --- a/spec/mongoid/criteria/queryable/storable_spec.rb +++ b/spec/mongoid/criteria/queryable/storable_spec.rb @@ -190,6 +190,78 @@ }) end end + + context 'when value is a hash combine values with different operator keys' do + let(:base) do + query.add_field_expression('foo', { '$in' => ['bar'] }) + end + + let(:modified) do + base.add_field_expression('foo', { '$nin' => ['zoom'] }) + end + + it 'combines the conditions using $and' do + expect(modified.selector).to eq({ + 'foo' => { + '$in' => ['bar'], + '$nin' => ['zoom'] + } + }) + end + end + + context 'when value is a hash with symbol operator key combine values with different operator keys' do + let(:base) do + query.add_field_expression('foo', { :$in => ['bar'] }) + end + + let(:modified) do + base.add_field_expression('foo', { :$nin => ['zoom'] }) + end + + it 'combines the conditions using $and' do + expect(modified.selector).to eq({ + 'foo' => { + :$in => ['bar'], + :$nin => ['zoom'] + } + }) + end + end + + context 'when value is a hash add values with same operator keys using $and' do + let(:base) do + query.add_field_expression('foo', { '$in' => ['bar'] }) + end + + let(:modified) do + base.add_field_expression('foo', { '$in' => ['zoom'] }) + end + + it 'adds the new condition using $and' do + expect(modified.selector).to eq({ + 'foo' => { '$in' => ['bar'] }, + '$and' => ['foo' => { '$in' => ['zoom'] }] + }) + end + end + + context 'when value is a hash with symbol operator key add values with same operator keys using $and' do + let(:base) do + query.add_field_expression('foo', { :$in => ['bar'] }) + end + + let(:modified) do + base.add_field_expression('foo', { :$in => ['zoom'] }) + end + + it 'adds the new condition using $and' do + expect(modified.selector).to eq({ + 'foo' => { :$in => ['bar'] }, + '$and' => ['foo' => { :$in => ['zoom'] }] + }) + end + end end describe '#add_operator_expression' do diff --git a/spec/mongoid/extensions/hash_spec.rb b/spec/mongoid/extensions/hash_spec.rb index 14325a327..0f2d91ee4 100644 --- a/spec/mongoid/extensions/hash_spec.rb +++ b/spec/mongoid/extensions/hash_spec.rb @@ -178,7 +178,7 @@ it 'moves the non hash values under the provided key' do expect(consolidated).to eq({ - '$set' => { name: 'Tool', likes: 10 }, '$inc' => { plays: 1 } + '$set' => { 'name' => 'Tool', likes: 10 }, '$inc' => { 'plays' => 1 } }) end end @@ -195,7 +195,7 @@ it 'moves the non hash values under the provided key' do expect(consolidated).to eq({ - '$set' => { likes: 10, name: 'Tool' }, '$inc' => { plays: 1 } + '$set' => { likes: 10, 'name' => 'Tool' }, '$inc' => { 'plays' => 1 } }) end end @@ -213,7 +213,7 @@ it 'moves the non hash values under the provided key' do expect(consolidated).to eq({ - '$set' => { likes: 10, name: 'Tool' }, '$inc' => { plays: 1 } + '$set' => { likes: 10, name: 'Tool' }, '$inc' => { 'plays' => 1 } }) end end diff --git a/spec/mongoid/fields_spec.rb b/spec/mongoid/fields_spec.rb index 0edca368a..5c0158ac6 100644 --- a/spec/mongoid/fields_spec.rb +++ b/spec/mongoid/fields_spec.rb @@ -558,6 +558,49 @@ end end end + + context 'when the field is declared as BSON::Decimal128' do + let(:document) { Mop.create!(decimal128_field: BSON::Decimal128.new(Math::PI.to_s)).reload } + + shared_examples 'BSON::Decimal128 is BigDecimal' do + it 'returns a BigDecimal' do + expect(document.decimal128_field).to be_a BigDecimal + end + end + + shared_examples 'BSON::Decimal128 is BSON::Decimal128' do + it 'returns a BSON::Decimal128' do + expect(document.decimal128_field).to be_a BSON::Decimal128 + end + end + + it 'is declared as BSON::Decimal128' do + expect(Mop.fields['decimal128_field'].type).to be == BSON::Decimal128 + end + + context 'when BSON version <= 4' do + max_bson_version '4.99.99' + it_behaves_like 'BSON::Decimal128 is BSON::Decimal128' + end + + context 'when BSON version >= 5' do + min_bson_version '5.0.0' + + context 'when allow_bson5_decimal128 is false' do + config_override :allow_bson5_decimal128, false + it_behaves_like 'BSON::Decimal128 is BigDecimal' + end + + context 'when allow_bson5_decimal128 is true' do + config_override :allow_bson5_decimal128, true + it_behaves_like 'BSON::Decimal128 is BSON::Decimal128' + end + + context 'when allow_bson5_decimal128 is default' do + it_behaves_like 'BSON::Decimal128 is BigDecimal' + end + end + end end describe '#getter_before_type_cast' do diff --git a/spec/mongoid/interceptable_spec.rb b/spec/mongoid/interceptable_spec.rb index 2931b7fac..d24df6741 100644 --- a/spec/mongoid/interceptable_spec.rb +++ b/spec/mongoid/interceptable_spec.rb @@ -587,9 +587,22 @@ class TestClass context 'when saving the root' do - it 'only executes the callbacks once for each embed' do - expect(note).to receive(:update_saved).twice - band.save! + context 'with prevent_multiple_calls_of_embedded_callbacks enabled' do + config_override :prevent_multiple_calls_of_embedded_callbacks, true + + it 'executes the callbacks only once for each document' do + expect(note).to receive(:update_saved).once + band.save! + end + end + + context 'with prevent_multiple_calls_of_embedded_callbacks disabled' do + config_override :prevent_multiple_calls_of_embedded_callbacks, false + + it 'executes the callbacks once for each member' do + expect(note).to receive(:update_saved).twice + band.save! + end end end end diff --git a/spec/mongoid/railties/bson_object_id_serializer_spec.rb b/spec/mongoid/railties/bson_object_id_serializer_spec.rb new file mode 100644 index 000000000..94a934cb4 --- /dev/null +++ b/spec/mongoid/railties/bson_object_id_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'active_job' +require 'mongoid/railties/bson_object_id_serializer' + +describe Mongoid::Railties::ActiveJobSerializers::BsonObjectIdSerializer do + let(:serializer) { described_class.instance } + let(:object_id) { BSON::ObjectId.new } + + describe '#serialize' do + it 'serializes BSON::ObjectId' do + expect(serializer.serialize(object_id)).to be_a(String) + end + end + + describe '#deserialize' do + it 'deserializes BSON::ObjectId' do + expect(serializer.deserialize(serializer.serialize(object_id))).to eq(object_id) + end + end +end diff --git a/spec/mongoid/reloadable_spec.rb b/spec/mongoid/reloadable_spec.rb index 5585a067c..7a68bc60e 100644 --- a/spec/mongoid/reloadable_spec.rb +++ b/spec/mongoid/reloadable_spec.rb @@ -328,6 +328,30 @@ end end + context 'when embeds_many is modified' do + let(:contractor1) { Contractor.new(name: 'b') } + let(:contractor2) { Contractor.new(name: 'c') } + + let(:building) do + Building.create!(name: 'a', contractors: [contractor1]) + end + + let(:more_contractors) { building.contractors + [contractor2] } + + let(:modified_building) do + building.tap do + building.assign_attributes contractors: more_contractors + end + end + + let(:reloaded_building) { modified_building.reload } + + it 'resets delayed_atomic_sets' do + expect(modified_building.delayed_atomic_sets).to_not be_empty + expect(reloaded_building.delayed_atomic_sets).to be_empty + end + end + context 'when embedded document is nil' do let(:palette) do diff --git a/spec/mongoid/tasks/database_rake_spec.rb b/spec/mongoid/tasks/database_rake_spec.rb index 3eef4b895..4d3b0d241 100644 --- a/spec/mongoid/tasks/database_rake_spec.rb +++ b/spec/mongoid/tasks/database_rake_spec.rb @@ -26,7 +26,7 @@ shared_examples_for 'create_indexes' do - it 'receives create_indexes' do + it 'receives create_indexes', skip: 'MONGOID-5656' do expect(Mongoid::Tasks::Database).to receive(:create_indexes) task.invoke end @@ -352,18 +352,18 @@ expect_any_instance_of(Mongo::ClientEncryption) .to receive(:create_data_key) - .with('local') + .with('local', {}) .and_call_original end - it 'creates the key' do + it 'creates the key', skip: 'MONGOID-5656' do task.invoke end context 'when using rails task' do include_context 'rails rake task' - it 'creates the key' do + it 'creates the key', skip: 'MONGOID-5656' do task.invoke end end diff --git a/spec/mongoid/tasks/encryption_spec.rb b/spec/mongoid/tasks/encryption_spec.rb index 68067e527..1a0aad464 100644 --- a/spec/mongoid/tasks/encryption_spec.rb +++ b/spec/mongoid/tasks/encryption_spec.rb @@ -30,27 +30,36 @@ BSON::Binary.new('data_key_id', :uuid) end + let(:key_alt_name) do + 'mongoid_test_alt_name' + end + before do key_vault_client[key_vault_collection].drop Mongoid::Config.send(:clients=, config) end context 'when all parameters are correct' do - before do - expect_any_instance_of(Mongo::ClientEncryption) - .to receive(:create_data_key) - .with('local') - .and_return(data_key_id) - end - context 'when all parameters are provided' do + before do + expect_any_instance_of(Mongo::ClientEncryption) + .to receive(:create_data_key) + .with('local', { key_alt_names: [key_alt_name] }) + .and_return(data_key_id) + end + it 'creates a data key' do - result = Mongoid::Tasks::Encryption.create_data_key(kms_provider_name: 'local', client_name: :encrypted) + result = Mongoid::Tasks::Encryption.create_data_key( + kms_provider_name: 'local', + client_name: :encrypted, + key_alt_name: key_alt_name + ) expect(result).to eq( { key_id: Base64.strict_encode64(data_key_id.data), key_vault_namespace: key_vault_namespace, - kms_provider: 'local' + kms_provider: 'local', + key_alt_name: key_alt_name } ) end @@ -58,15 +67,49 @@ context 'when kms_provider_name is not provided' do context 'and there is only one kms provider' do - it 'creates a data key' do - result = Mongoid::Tasks::Encryption.create_data_key(client_name: :encrypted) - expect(result).to eq( - { - key_id: Base64.strict_encode64(data_key_id.data), - key_vault_namespace: key_vault_namespace, - kms_provider: 'local' - } - ) + + context 'without key_alt_name' do + before do + expect_any_instance_of(Mongo::ClientEncryption) + .to receive(:create_data_key) + .with('local', {}) + .and_return(data_key_id) + end + + it 'creates a data key' do + result = Mongoid::Tasks::Encryption.create_data_key(client_name: :encrypted) + expect(result).to eq( + { + key_id: Base64.strict_encode64(data_key_id.data), + key_vault_namespace: key_vault_namespace, + kms_provider: 'local' + } + ) + end + end + + context 'with key_alt_name' do + before do + expect_any_instance_of(Mongo::ClientEncryption) + .to receive(:create_data_key) + .with('local', { key_alt_names: [key_alt_name] }) + .and_return(data_key_id) + end + + it 'creates a data key' do + result = Mongoid::Tasks::Encryption.create_data_key( + client_name: :encrypted, + key_alt_name: key_alt_name + ) + expect(result).to eq( + { + key_id: Base64.strict_encode64(data_key_id.data), + key_vault_namespace: key_vault_namespace, + kms_provider: 'local', + key_alt_name: key_alt_name + } + ) + end end end end diff --git a/spec/mongoid_spec.rb b/spec/mongoid_spec.rb index 79c4fa083..cc469b2d4 100644 --- a/spec/mongoid_spec.rb +++ b/spec/mongoid_spec.rb @@ -76,7 +76,7 @@ it 'disconnects from all active clients' do clients.each do |client| - expect(client.cluster).to receive(:disconnect!).and_call_original + expect(client).to receive(:close).and_call_original end described_class.disconnect_clients end diff --git a/spec/support/crypt.rb b/spec/support/crypt.rb index 7560df70b..05460fefe 100644 --- a/spec/support/crypt.rb +++ b/spec/support/crypt.rb @@ -68,7 +68,7 @@ module Crypt if (data_key = client_encryption.get_key_by_alt_name(key_alt_name)) Base64.encode64(data_key['_id'].data) else - key_id = client_encryption.create_data_key('local', key_alt_name: key_alt_name) + key_id = client_encryption.create_data_key('local', key_alt_names: [key_alt_name]) Base64.encode64(key_id.data).strip end end diff --git a/spec/support/crypt/models.rb b/spec/support/crypt/models.rb index d7ea60ca0..b8b9c9bc1 100644 --- a/spec/support/crypt/models.rb +++ b/spec/support/crypt/models.rb @@ -8,8 +8,12 @@ class Patient field :code, type: String field :medical_records, type: Array, encrypt: { deterministic: false } - field :blood_type, type: String, encrypt: { deterministic: false } + field :blood_type, type: String, encrypt: { + deterministic: false, + key_name_field: :blood_type_key_name + } field :ssn, type: Integer, encrypt: { deterministic: true } + field :blood_type_key_name, type: String embeds_one :insurance, class_name: 'Crypt::Insurance' end @@ -37,6 +41,8 @@ class User class Car include Mongoid::Document + store_in database: 'vehicles' + encrypt_with key_id: 'grolrnFVSSW9Gq04Q87R9Q==', deterministic: true field :vin, type: String, encrypt: true diff --git a/spec/support/models/person.rb b/spec/support/models/person.rb index 7c5772995..478e73a02 100644 --- a/spec/support/models/person.rb +++ b/spec/support/models/person.rb @@ -71,6 +71,7 @@ def find_by_street(street) embeds_many :messages, validate: false embeds_one :passport, autobuild: true, store_as: :pass, validate: false + embeds_one :purse, store_as: 'Purse' embeds_one :pet, class_name: 'Animal', validate: false embeds_one :name, as: :namable, validate: false do def extension diff --git a/spec/support/models/purse.rb b/spec/support/models/purse.rb new file mode 100644 index 000000000..d26226b9c --- /dev/null +++ b/spec/support/models/purse.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Purse + include Mongoid::Document + + field :brand, type: String + + embedded_in :person +end diff --git a/upload-api-docs b/upload-api-docs new file mode 100755 index 000000000..a24d24613 --- /dev/null +++ b/upload-api-docs @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile true do + source 'https://rubygems.org' + gem 'nokogiri' + gem 'aws-sdk-s3' + gem 'yard' +end + +require 'aws-sdk-s3' +require 'optparse' +require 'yard' + +# This class contains logic for uploading API docs to S3. +class FileUploader + def initialize(options) + Aws.config.update({ + region: options[:region], + credentials: Aws::Credentials.new(options[:access_key], options[:secret_key]) + }) + Aws.use_bundled_cert! + @s3 = Aws::S3::Client.new + @bucket = options[:bucket] + @prefix = options[:prefix] + @docs_path = options[:docs_path] + end + + def upload_docs + Dir.glob("#{@docs_path}/**/*").each do |file| + next if File.directory?(file) + + upload_file(file, key(file)) + print '.' + $stdout.flush + end + puts "\nDone!" + end + + private + + def key(file) + File.join(@prefix, file.gsub("#{@docs_path}/", '')) + end + + def upload_file(file, key) + mime_type = mime_type(file) + @s3.put_object(bucket: @bucket, key: key, body: File.read(file), content_type: mime_type) + end + + def mime_type(file) + { + '.html' => 'text/html', + '.css' => 'text/css', + '.js' => 'application/javascript' + }.fetch(File.extname(file)) + end +end + +# This class contains logic for parsing CLI and ENV options. +class Options + def initialize + @options = {} + parse_cli_options! + parse_env_options! + @options[:prefix] = 'docs/mongoid/master/api' + @options[:docs_path] = 'build/public/master/api' + end + + def [](key) + @options[key] + end + + private + + def parse_cli_options! + OptionParser.new do |opts| + opts.banner = 'Usage: upload-api-docs [options]' + + opts.on('-b BUCKET', '--bucket=BUCKET', 'S3 Bucket to upload') do |b| + @options[:bucket] = b + end + opts.on('-r REGION', '--region=REGION', 'AWS region') do |r| + @options[:region] = r + end + end.parse! + %i[bucket region].each do |opt| + raise OptionParser::MissingArgument.new("Option --#{opt} is required") unless @options[opt] + end + end + + def parse_env_options! + @options[:access_key] = ENV.fetch('DOCS_AWS_ACCESS_KEY_ID') do + raise ArgumentError.new('Please provide aws access key via DOCS_AWS_ACCESS_KEY_ID env variable') + end + @options[:secret_key] = ENV.fetch('DOCS_AWS_SECRET_ACCESS_KEY') do + raise ArgumentError.new('Please provide aws secret key via DOCS_AWS_SECRET_ACCESS_KEY env variable') + end + end +end + +def generate_docs(options) + YARD::CLI::Yardoc.run( + '.', + '--exclude', './.evergreen', + '--exclude', './.mod', + '--exclude', './examples', + '--exclude', './perf', + '--exclude', './profile', + '--exclude', './release', + '--exclude', './spec', + '--exclude', './test-apps', + '--readme', './README.md', + '-o', options[:docs_path] + ) +end + +options = Options.new +generate_docs(options) +FileUploader.new(options).upload_docs