diff --git a/docs/reference/queries.txt b/docs/reference/queries.txt index 8622828e2b..408c4e9f7e 100644 --- a/docs/reference/queries.txt +++ b/docs/reference/queries.txt @@ -168,58 +168,45 @@ name, as follows: # class: Band # embedded: false> -Embedded Documents -================== - -To match values of fields of embedded documents, use the dot notation: - -.. code-block:: ruby - - Band.where('manager.name' => 'Smith') - # => #"Smith"} - # options: {} - # class: Band - # embedded: false> - - Band.where(:'manager.name'.ne => 'Smith') - # => #{"$ne"=>"Smith"}} - # options: {} - # class: Band - # embedded: false> -.. note:: +Fields +====== - Queries always return top-level model instances, even if all of the - conditions are referencing embedded documents. - -Field Types -=========== +Querying on Defined Fields +-------------------------- In order to query on a field, it is not necessary to add the field to :ref:`the model class definition `. However, if a field is defined in -the model class, the type of the field is taken into account when constructing -the query: +the model class, Mongoid will coerce query values to match defined field types +when constructing the query: .. code-block:: ruby - Band.where(name: 2020) + Band.where(name: 2020, founded: "2020") # => #"2020"} + # selector: {"name"=>"2020", "founded"=>2020} # options: {} # class: Band # embedded: false> - Band.where(founded: 2020) +Querying for Raw Values +----------------------- + +If you'd like to bypass Mongoid's query type coercion behavior and query +directly for the raw-typed value in the database, wrap the query value in +``Mongoid::RawValue`` class. This can be useful when working with legacy data. + +.. code-block:: ruby + + Band.where(founded: Mongoid::RawValue("2020")) # => #2020} + # selector: {"founded"=>"2020"} # options: {} # class: Band # embedded: false> -Aliases -======= +Field Aliases +------------- Queries take into account :ref:`storage field names ` and :ref:`field aliases `: @@ -245,6 +232,33 @@ Since ``id`` and ``_id`` fields are aliases, either one can be used for queries: # embedded: false> +Embedded Documents +================== + +To match values of fields of embedded documents, use the dot notation: + +.. code-block:: ruby + + Band.where('manager.name' => 'Smith') + # => #"Smith"} + # options: {} + # class: Band + # embedded: false> + + Band.where(:'manager.name'.ne => 'Smith') + # => #{"$ne"=>"Smith"}} + # options: {} + # class: Band + # embedded: false> + +.. note:: + + Queries always return top-level model instances, even if all of the + conditions are referencing embedded documents. + + .. _logical-operations: Logical Operations diff --git a/docs/release-notes/mongoid-9.0.txt b/docs/release-notes/mongoid-9.0.txt index 5813dc9fd4..61aab869e9 100644 --- a/docs/release-notes/mongoid-9.0.txt +++ b/docs/release-notes/mongoid-9.0.txt @@ -82,6 +82,24 @@ ignored for embedded documents; an embedded document now always uses the persist context of its parent. +Support for Passing Raw Values into Queries +------------------------------------------- + +When performing queries, it is now possible skip Mongoid's type coercion logic +using the ``Mongoid::RawValue`` wrapper class. This can be useful when legacy +data in the database is of a different type than the field definition. + +.. code-block:: ruby + + class Person + include Mongoid::Document + field :age, type: Integer + end + + # Query for the string "42", not the integer 42 + Person.where(age: Mongoid::RawValue("42")) + + Raise AttributeNotLoaded error when accessing fields omitted from query projection ---------------------------------------------------------------------------------- diff --git a/lib/mongoid/criteria/queryable/selector.rb b/lib/mongoid/criteria/queryable/selector.rb index 66fd249001..8d1e12dec4 100644 --- a/lib/mongoid/criteria/queryable/selector.rb +++ b/lib/mongoid/criteria/queryable/selector.rb @@ -75,7 +75,7 @@ def to_pipeline # Get the store name and store value. If the value is of type range, # we need may need to change the store_name as well as the store_value, - # therefore, we cannot just use the evole method. + # therefore, we cannot just use the evolve method. # # @param [ String ] name The name of the field. # @param [ Object ] serializer The optional serializer for the field. @@ -151,6 +151,8 @@ def evolve_multi(specs) # @return [ Object ] The serialized object. def evolve(serializer, value) case value + when Mongoid::RawValue + value.raw_value when Hash evolve_hash(serializer, value) when Array @@ -228,7 +230,7 @@ def evolve_hash(serializer, value) # # @api private # - # @param [ String ] key The to store the range for. + # @param [ String ] key The key at which to store the range. # @param [ Object ] serializer The optional serializer for the field. # @param [ Range ] value The Range to serialize. # diff --git a/lib/mongoid/extensions.rb b/lib/mongoid/extensions.rb index 514929ed23..49cdbff819 100644 --- a/lib/mongoid/extensions.rb +++ b/lib/mongoid/extensions.rb @@ -48,6 +48,7 @@ def transform_keys require "mongoid/extensions/object" require "mongoid/extensions/object_id" require "mongoid/extensions/range" +require "mongoid/extensions/raw_value" require "mongoid/extensions/regexp" require "mongoid/extensions/set" require "mongoid/extensions/string" diff --git a/lib/mongoid/extensions/raw_value.rb b/lib/mongoid/extensions/raw_value.rb new file mode 100644 index 0000000000..9343ae52bf --- /dev/null +++ b/lib/mongoid/extensions/raw_value.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Wrapper class used when a value cannot be casted in evolve method. +module Mongoid + + # Instantiates a new Mongoid::RawValue object. Used as a syntax shortcut. + # + # @example Create a Mongoid::RawValue object. + # Mongoid::RawValue("Beagle") + # + # @return [ Mongoid::RawValue ] The object. + def RawValue(*args) + RawValue.new(*args) + end + + class RawValue + + attr_reader :raw_value + + def initialize(raw_value) + @raw_value = raw_value + end + + # Returns a string containing a human-readable representation of + # the object, including the inspection of the underlying value. + # + # @return [ String ] The object inspection. + def inspect + "RawValue: #{raw_value.inspect}" + end + end +end diff --git a/spec/integration/criteria/raw_value_spec.rb b/spec/integration/criteria/raw_value_spec.rb new file mode 100644 index 0000000000..e1756d9bf4 --- /dev/null +++ b/spec/integration/criteria/raw_value_spec.rb @@ -0,0 +1,525 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Queries with Mongoid::RawValue criteria' do + before { Time.zone = 'UTC'} + let(:now_utc) { Time.utc(2020, 1, 1, 16, 0, 0, 0) } + let(:today) { Date.new(2020, 1, 1) } + + let(:labels) do + [ Label.new(age: 12), Label.new(age: 16) ] + end + + let!(:band1) { Band.create!(name: '1', likes: 0, rating: 0.9, sales: BigDecimal('90'), decibels: 20..80, founded: today, updated: now_utc) } + let!(:band2) { Band.create!(name: '2', likes: 1, rating: 1.0, sales: BigDecimal('100'), decibels: 30..90, founded: today, updated: now_utc + 1.days) } + let!(:band3) { Band.create!(name: '3', likes: 1, rating: 2.2, sales: BigDecimal('220'), decibels: 40..100, founded: today + 1.days, updated: now_utc + 2.days) } + let!(:band4) { Band.create!(name: '3', likes: 2, rating: 3.1, sales: BigDecimal('310'), decibels: 50..120, founded: today + 1.days, updated: now_utc + 3.days) } + let!(:band5) { Band.create!(name: '4', likes: 3, rating: 3.1, sales: BigDecimal('310'), decibels: 60..150, founded: today + 2.days, updated: now_utc + 3.days, labels: labels) } + + let!(:band6) do + id = BSON::ObjectId.new + Band.collection.insert_one(_id: id, name: 1, likes: '1', rating: '3.1', sales: '310', decibels: '90', founded: '2020-01-02', updated: '2020-01-04 16:00:00 UTC') + Band.find(id) + end + + let!(:band7) do + id = BSON::ObjectId.new + Band.collection.insert_one(_id: id, name: 1.0, decibels: 90.0, founded: 1577923200, updated: 1578153600) + Band.find(id) + end + + context 'Mongoid::RawValue criteria' do + + context 'Integer field' do + it 'does not match objects' do + expect(Band.where(likes: Mongoid::RawValue('1')).to_a).to eq [band6] + end + + it 'matches objects without raw value' do + expect(Band.where(likes: '1').to_a).to eq [band2, band3] + end + end + + context 'Float field' do + it 'does not match objects' do + expect(Band.where(rating: Mongoid::RawValue('3.1')).to_a).to eq [band6] + end + + it 'matches objects with value stored as Float' do + expect(Band.where(rating: '3.1').to_a).to eq [band4, band5] + end + end + + context 'BigDecimal field' do + it 'does not match objects with raw value' do + expect(Band.where(sales: Mongoid::RawValue('310')).to_a).to eq [band6] + end + + it 'matches objects with value stored as Decimal128' do + expect(Band.where(sales: '310').to_a).to eq [band4, band5] + end + end + + context 'String field' do + it 'matches objects' do + expect(Band.where(name: Mongoid::RawValue('3')).to_a).to eq [band3, band4] + end + + it 'matches objects without raw value' do + expect(Band.where(name: '3').to_a).to eq [band3, band4] + end + end + + context 'Range field' do + it 'does not match objects with raw value' do + expect(Band.where(decibels: Mongoid::RawValue('90')).to_a).to eq [band6] + end + + it 'matches objects without raw value because String cannot be evolved to Range' do + expect(Band.where(decibels: '90').to_a).to eq [band6] + end + end + + context 'Date field' do + it 'does not match objects with raw value' do + expect(Band.where(founded: Mongoid::RawValue('2020-01-02')).to_a).to eq [band6] + end + + it 'matches objects without raw value' do + expect(Band.where(founded: '2020-01-02').to_a).to eq [band3, band4] + end + end + + context 'Time field' do + it 'does not match objects with raw value' do + expect(Band.where(updated: Mongoid::RawValue('2020-01-04 16:00:00 UTC')).to_a).to eq [band6] + end + + it 'matches objects without raw value' do + expect(Band.where(updated: '2020-01-04 16:00:00 UTC').to_a).to eq [band4, band5] + end + end + end + + context 'Mongoid::RawValue' do + + context 'Integer field' do + it 'matches objects with raw value' do + expect(Band.where(likes: Mongoid::RawValue(1)).to_a).to eq [band2, band3] + end + + it 'matches objects without raw value' do + expect(Band.where(likes: 1).to_a).to eq [band2, band3] + end + end + + context 'Float field' do + it 'does not match objects with raw value' do + expect(Band.where(rating: Mongoid::RawValue(1)).to_a).to eq [band2] + expect(Band.where(rating: Mongoid::RawValue(3)).to_a).to eq [] + end + + it 'matches objects without raw value' do + expect(Band.where(rating: 1).to_a).to eq [band2] + expect(Band.where(rating: 3).to_a).to eq [] + end + end + + context 'BigDecimal field' do + it 'matches objects with raw value' do + expect(Band.where(sales: Mongoid::RawValue(310)).to_a).to eq [band4, band5] + end + + it 'matches objects without raw value' do + expect(Band.where(sales: 310).to_a).to eq [band4, band5] + end + end + + context 'String field' do + it 'matches objects with raw value' do + expect(Band.where(name: Mongoid::RawValue(1)).to_a).to eq [band6, band7] + end + + it 'matches objects without raw value' do + expect(Band.where(name: 3).to_a).to eq [band3, band4] + end + end + + context 'Range field' do + it 'does not match objects with raw value' do + expect(Band.where(decibels: Mongoid::RawValue(90)).to_a).to eq [band7] + end + + it 'matches objects without raw value because Integer cannot be evolved to Range' do + expect(Band.where(decibels: 90).to_a).to eq [band7] + end + end + + context 'Date field' do + it 'does not match objects with raw value' do + expect(Band.where(founded: Mongoid::RawValue(1577923200)).to_a).to eq [band7] + end + + it 'matches objects without raw value' do + expect(Band.where(founded: 1577923200).to_a).to eq [band3, band4] + end + end + + context 'Time field' do + it 'does not match objects with raw value' do + expect(Band.where(updated: Mongoid::RawValue(1578153600)).to_a).to eq [band7] + end + + it 'matches objects without raw value' do + expect(Band.where(updated: 1578153600).to_a).to eq [band4, band5] + end + end + end + + context 'Mongoid::RawValue' do + + context 'Integer field' do + it 'does not match objects with raw value' do + expect(Band.where(likes: Mongoid::RawValue(1.0)).to_a).to eq [band2, band3] + end + + it 'matches objects without raw value' do + expect(Band.where(likes: 1.0).to_a).to eq [band2, band3] + end + end + + context 'Float field' do + it 'does not match objects with raw value' do + expect(Band.where(rating: Mongoid::RawValue(3.1)).to_a).to eq [band4, band5] + end + + it 'matches objects without raw value' do + expect(Band.where(rating: 3.1).to_a).to eq [band4, band5] + end + end + + context 'BigDecimal field' do + it 'matches objects with raw value' do + expect(Band.where(sales: Mongoid::RawValue(310.0)).to_a).to eq [band4, band5] + end + + it 'matches objects without raw value' do + expect(Band.where(sales: 310.0).to_a).to eq [band4, band5] + end + end + + context 'String field' do + it 'matches objects with raw value' do + expect(Band.where(name: Mongoid::RawValue(1.0)).to_a).to eq [band6, band7] + end + + it 'matches objects without raw value' do + expect(Band.where(name: 1.0).to_a).to eq [] + end + end + + context 'Range field' do + it 'does not match objects with raw value' do + expect(Band.where(decibels: Mongoid::RawValue(90.0)).to_a).to eq [band7] + end + + it 'matches objects without raw value because Float cannot be evolved to Range' do + expect(Band.where(decibels: 90.0).to_a).to eq [band7] + end + end + + context 'Date field' do + it 'does not match objects with raw value' do + expect(Band.where(founded: Mongoid::RawValue(1577923200.0)).to_a).to eq [band7] + end + + it 'matches objects without raw value' do + expect(Band.where(founded: 1577923200.0).to_a).to eq [band3, band4] + end + end + + context 'Time field' do + it 'does not match objects with raw value' do + expect(Band.where(updated: Mongoid::RawValue(1578153600.0)).to_a).to eq [band7] + end + + it 'matches objects without raw value' do + expect(Band.where(updated: 1578153600.0).to_a).to eq [band4, band5] + end + end + end + + context 'Mongoid::RawValue' do + + context 'Integer field' do + it 'does not match objects with raw value' do + expect(Band.where(likes: Mongoid::RawValue(BigDecimal('1'))).to_a).to eq [band2, band3] + end + + it 'matches objects without raw value' do + expect(Band.where(likes: BigDecimal('1')).to_a).to eq [band2, band3] + end + end + + context 'Float field' do + it 'does not exact match objects with raw value due to float imprecision' do + expect(Band.where(rating: Mongoid::RawValue(BigDecimal('3.1'))).to_a).to eq [] + end + + it 'fuzzy matches objects with raw value' do + expect(Band.gte(rating: Mongoid::RawValue(BigDecimal('3.099'))).lte(rating: Mongoid::RawValue(BigDecimal('3.101'))).to_a).to eq [band4, band5] + end + + it 'matches objects without raw value' do + expect(Band.where(rating: BigDecimal('3.1')).to_a).to eq [band4, band5] + end + end + + context 'BigDecimal field' do + it 'matches objects with raw value' do + expect(Band.where(sales: Mongoid::RawValue(BigDecimal('310'))).to_a).to eq [band4, band5] + end + + it 'matches objects without raw value' do + expect(Band.where(sales: BigDecimal('310')).to_a).to eq [band4, band5] + end + end + + context 'String field' do + it 'matches objects with raw value' do + expect(Band.where(name: Mongoid::RawValue(BigDecimal('1'))).to_a).to eq [band6, band7] + end + + it 'does not match objects without raw value' do + expect(Band.where(name: BigDecimal('1')).to_a).to eq [] + end + end + + context 'Range field' do + it 'matches objects with raw value' do + expect(Band.where(decibels: Mongoid::RawValue(BigDecimal('90'))).to_a).to eq [band7] + end + + it 'matches objects without raw value because BigDecimal cannot be evolved to Range' do + expect(Band.where(decibels: BigDecimal('90')).to_a).to eq [band7] + end + end + + context 'Date field' do + it 'does not match objects with raw value' do + expect(Band.where(founded: Mongoid::RawValue(BigDecimal('1577923200'))).to_a).to eq [band7] + end + + it 'matches objects without raw value because BigDecimal cannot be evolved to Date' do + expect(Band.where(founded: BigDecimal('1577923200')).to_a).to eq [band7] + end + end + + context 'Time field' do + it 'does not match objects with raw value' do + expect(Band.where(updated: Mongoid::RawValue(BigDecimal('1578153600'))).to_a).to eq [band7] + end + + it 'matches objects without raw value because BigDecimal cannot be evolved to Time' do + expect(Band.where(updated: BigDecimal('1578153600')).to_a).to eq [band7] + end + end + end + + context 'Mongoid::RawValue' do + + context 'Integer field' do + it 'raises a BSON error with raw value' do + expect { Band.where(likes: Mongoid::RawValue(0..2)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(likes: 0..2).to_a).to eq [band1, band2, band3, band4] + end + end + + context 'Float field' do + it 'raises a BSON error with raw value' do + expect { Band.where(rating: Mongoid::RawValue(1..3)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(rating: 1..3).to_a).to eq [band2, band3] + end + end + + context 'BigDecimal field' do + it 'raises a BSON error with raw value' do + expect { Band.where(sales: Mongoid::RawValue(100..300)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(sales: 100..300).to_a).to eq [band2, band3] + end + end + + context 'String field' do + it 'raises a BSON error with raw value' do + expect { Band.where(name: Mongoid::RawValue(1..3)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(name: 1..3).to_a).to eq [band1, band2, band3, band4] + end + end + + context 'Range field' do + it 'raises a BSON error with raw value' do + expect { Band.where(decibels: Mongoid::RawValue(30..90)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value because Range is evolved into a gte/lte query range' do + expect(Band.where(decibels: 30..90).to_a).to eq [band7] + expect(Band.where(decibels: 20..100).to_a).to eq [band7] + end + end + + context 'Date field' do + it 'raises a BSON error with raw value' do + expect { Band.where(founded: Mongoid::RawValue(1577923199..1577923201)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(founded: 1577923199..1577923201).to_a).to eq [band1, band2, band3, band4] + end + end + + context 'Time field' do + it 'raises a BSON error with raw value' do + expect { Band.where(founded: Mongoid::RawValue(1578153599..1578153600)).to_a }.to raise_error BSON::Error::UnserializableClass + end + + it 'matches objects without raw value' do + expect(Band.where(updated: 1578153599..1578153600).to_a).to eq [band4, band5] + end + end + end + + context 'Mongoid::RawValue