Skip to content

Commit

Permalink
MONGOID-4913 Implement StringifiedSymbol type (#4906)
Browse files Browse the repository at this point in the history
  • Loading branch information
ksadoff authored Nov 5, 2020
1 parent fee0cfe commit 49c2c00
Show file tree
Hide file tree
Showing 7 changed files with 414 additions and 26 deletions.
97 changes: 71 additions & 26 deletions docs/tutorials/mongoid-documents.txt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ Below is a list of valid types for fields.
- ``Regexp``
- ``Set``
- ``String``
- ``StringifiedSymbol``
- ``Symbol``
- ``Time``
- ``TimeWithZone``
Expand Down Expand Up @@ -176,6 +177,58 @@ Types that are not supported as dynamic attributes since they cannot be cast are
- ``DateTime``
- ``Range``

.. _stringified-symbol:

The StringifiedSymbol Type
--------------------------

The ``StringifiedSymbol`` field type is the recommended field type for storing
values that should be exposed as symbols to Ruby applications. When using the ``Symbol`` field type,
Mongoid defaults to storing values as BSON symbols. For more information on the
BSON symbol type, see :ref:`here <bson-symbol>`.
However, the BSON symbol type is deprecated and is difficult to work with in programming languages
without native symbol types, so the ``StringifiedSymbol`` type allows the use of symbols
while ensuring interoperability with other drivers. The ``StringifiedSymbol`` type stores all data
on the database as strings, while exposing values to the application as symbols.

An example usage is shown below:

.. code-block:: ruby

class Post
include Mongoid::Document

field :status, type: StringifiedSymbol
end

post = Post.new(status: :hello)
# status is stored as "hello" on the database, but returned as a Symbol
post.status
# => :hello

# String values can be assigned also:
post = Post.new(status: "hello")
# status is stored as "hello" on the database, but returned as a Symbol
post.status
# => :hello

All non-string values will be stringified upon being sent to the database (via ``to_s``), and
all values will be converted to symbols when returned to the application. Values that cannot be
converted directly to symbols, such as integers and arrays, will first be converted to strings and
then symbols before being returned to the application.

For example, setting an integer as ``status``:

.. code-block:: ruby

post = Post.new(status: 42)
post.status
# => :"42"

If the ``StringifiedSymbol`` type is applied to a field that contains BSON symbols, the values
will be stored as strings instead of BSON symbols on the next save. This permits transparent lazy
migration from fields that currently store either strings or BSON symbols in the database to the
``StringifiedSymbol`` field type.

Updating Container Fields
-------------------------
Expand Down Expand Up @@ -706,35 +759,27 @@ For cases when you do not want to have ``BSON::ObjectId`` ids, you can override
field :_id, type: String, default: ->{ name }
end

.. _bson-symbol:

BSON Symbol Type
----------------

Because the BSON specification deprecated the BSON symbol type, the `bson` gem will serialize Ruby
symbols into BSON strings when used on its own. However, in order to maintain backwards
compatibility with older datasets, the `mongo` gem overrides this behavior to serialize Ruby symbols as
BSON symbols. This is necessary to be able to specify queries for documents which contain BSON
symbols as fields.

Although Mongoid allows applications to define fields with the Symbol type, this could present
problems when using other MongoDB tools that have removed support for the type. Because of this,
new applications should not specify model fields with the Symbol type but instead use the String
type. Existing applications with Symbol model fields may convert those fields to Strings using one
of the following methods:

- Eager migration: write a script or rake task that queries each document and
updates any symbols it finds to strings. This will take a long time for larger data sets, and
unless the application can handle two different schemas existing in the data at once, it will
need to be taken offline while the script runs.

- Lazy migration: rather than updating all of the documents at once, each document received from a
query should be reinserted if it contains a symbol; since the BSON received will have already
turned the symbols into strings, this will update the document as necessary. This can be done
by simply calling the ``save`` method. Note that this method will only convert documents that
are accessed by the application, eventually eager migration may be needed to convert the
remaining documents that haven't been accessed.

To override default behaivor and configure the ``mongo`` gem (and thereby Mongoid as well)
to encode symbol values as strings, include the following code snippet in your project:
New applications should use the :ref:`StringifiedSymbol field type <stringified-symbol>`
to store Ruby symbols in the database. The ``StringifiedSymbol`` field type
provides maximum compatibility with other applications and programming languages
and has the same behavior in all circumstances.

Mongoid also provides the deprecated ``Symbol`` field type for serializing
Ruby symbols to BSON symbols. Because the BSON specification deprecated the
BSON symbol type, the `bson` gem will serialize Ruby symbols into BSON strings
when used on its own. However, in order to maintain backwards compatibility
with older datasets, the `mongo` gem overrides this behavior to serialize Ruby
symbols as BSON symbols. This is necessary to be able to specify queries for
documents which contain BSON symbols as fields.

To override the default behavior and configure the ``mongo`` gem (and thereby
Mongoid as well) to encode symbol values as strings, include the following code
snippet in your project:

.. code-block:: ruby

Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def transform_keys
require "mongoid/extensions/regexp"
require "mongoid/extensions/set"
require "mongoid/extensions/string"
require "mongoid/stringified_symbol"
require "mongoid/extensions/symbol"
require "mongoid/extensions/time"
require "mongoid/extensions/time_with_zone"
Expand Down
3 changes: 3 additions & 0 deletions lib/mongoid/fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ module Mongoid
module Fields
extend ActiveSupport::Concern

StringifiedSymbol = Mongoid::StringifiedSymbol

# For fields defined with symbols use the correct class.
#
# @since 4.0.0
Expand All @@ -30,6 +32,7 @@ module Fields
regexp: Regexp,
set: Set,
string: String,
stringified_symbol: StringifiedSymbol,
symbol: Symbol,
time: Time
}.with_indifferent_access
Expand Down
53 changes: 53 additions & 0 deletions lib/mongoid/stringified_symbol.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true
# encoding: utf-8

# A class which sends values to the database as Strings but returns them to the user as Symbols.
module Mongoid
class StringifiedSymbol

class << self

# Convert the object from its mongo friendly ruby type to this type.
#
# @example Demongoize the object.
# Symbol.demongoize(object)
#
# @param [ Object ] object The object to demongoize.
#
# @return [ Symbol ] The object.
#
# @api private
def demongoize(object)
if object.nil?
object
else
object.to_s.to_sym
end
end

# Turn the object from the ruby type we deal with to a Mongo friendly
# type.
#
# @example Mongoize the object.
# Symbol.mongoize("123.11")
#
# @param [ Object ] object The object to mongoize.
#
# @return [ Symbol ] The object mongoized.
#
# @api private
def mongoize(object)
if object.nil?
object
else
object.to_s
end
end

# @api private
def evolve(object)
mongoize(object)
end
end
end
end
190 changes: 190 additions & 0 deletions spec/integration/stringified_symbol_field_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
require "spec_helper"

describe "StringifiedSymbol fields" do

before do
Order.destroy_all
end

context "when querying the database" do

let!(:document) do
Order.create!(saved_status: :test)
end

let(:string_query) do
{'saved_status' => {'$eq' => 'test'}}
end

let(:symbol_query) do
{'saved_status' => {'$eq' => :test}}
end

it "can be queried with a string" do
doc = Order.where(string_query).first
expect(doc.saved_status).to eq(:test)
end

it "can be queried with a symbol" do
doc = Order.where(symbol_query).first
expect(doc.saved_status).to eq(:test)
end
end

# Using command monitoring to test that StringifiedSymbol sends a string and returns a symbol
let(:client) { Order.collection.client }

before do
client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
end

after do
client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
end

let(:subscriber) do
EventSubscriber.new
end

let(:find_events) do
subscriber.started_events.select { |event| event.command_name.to_s == 'find' }
end

let(:insert_events) do
subscriber.started_events.select { |event| event.command_name.to_s == 'insert' }
end

let(:update_events) do
subscriber.started_events.select { |event| event.command_name.to_s == 'update' }
end

before do
subscriber.clear_events!
end

let(:query) do
{'saved_status' => {'$eq' => 'test'}}
end

let!(:document1) do
Order.create!(saved_status: :test)
end

let!(:document2) do
Order.where(query).first
end

context "when inserting document" do

it "sends the value as a string" do
Order.create!(saved_status: :test)
event = insert_events.first
doc = event.command["documents"].first
expect(doc["saved_status"]).to eq("test")
end

it "sends the value as a string" do
Order.create!(saved_status: 42)
event = insert_events.second
doc = event.command["documents"].first
expect(doc["saved_status"]).to eq("42")
end

it "sends the value as a string" do
Order.create(saved_status: [0, 1, 2])
event = insert_events.second
doc = event.command["documents"].first
expect(doc["saved_status"]).to eq("[0, 1, 2]")
end
end

context "when finding document" do

it "receives the value as a symbol" do
event = find_events.first
expect(document2.saved_status).to eq(:test)
end
end

context "when reading a BSON Symbol field" do

before do
client["orders"].insert_one(saved_status: BSON::Symbol::Raw.new("test"), _id: 12)
end

it "receives the value as a symbol" do
expect(Order.find(12).saved_status).to eq(:test)
end

it "saves the value as a string" do
s = Order.find(12)
s.saved_status = :other
s.save!
event = update_events.first
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("other")
end
end

context "when value is nil" do

before do
client["orders"].insert_one(saved_status: nil, _id: 15)
end

it "returns nil" do
expect(Order.find(15).saved_status).to be_nil
end
end

context "when writing nil" do

before do
client["orders"].insert_one(saved_status: "hello", _id: 16)
end

it "saves the value as nil" do
s = Order.find(16)
s.saved_status = nil
s.save!
event = update_events.first
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to be_nil
end
end

context "when reading an integer" do

before do
client["orders"].insert_one(saved_status: 42, _id: 13)
end

it "receives the value as a symbol" do
expect(Order.find(13).saved_status).to eq(:"42")
end

it "saves the value as a string" do
s = Order.find(13)
s.saved_status = 24
s.save!
event = update_events.first
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("24")
end
end

context "when reading an array" do
before do
client["orders"].insert_one(saved_status: [0, 1, 2], _id: 14)
end

it "receives the value as a symbol" do
expect(Order.find(14).saved_status).to be(:"[0, 1, 2]")
end

it "saves the value as a string" do
s = Order.find(14)
s.saved_status = [3, 4, 5]
s.save!
event = update_events.first
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("[3, 4, 5]")
end
end
end
Loading

0 comments on commit 49c2c00

Please sign in to comment.