Skip to content

Commit 49c2c00

Browse files
authored
MONGOID-4913 Implement StringifiedSymbol type (#4906)
1 parent fee0cfe commit 49c2c00

File tree

7 files changed

+414
-26
lines changed

7 files changed

+414
-26
lines changed

docs/tutorials/mongoid-documents.txt

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ Below is a list of valid types for fields.
147147
- ``Regexp``
148148
- ``Set``
149149
- ``String``
150+
- ``StringifiedSymbol``
150151
- ``Symbol``
151152
- ``Time``
152153
- ``TimeWithZone``
@@ -176,6 +177,58 @@ Types that are not supported as dynamic attributes since they cannot be cast are
176177
- ``DateTime``
177178
- ``Range``
178179

180+
.. _stringified-symbol:
181+
182+
The StringifiedSymbol Type
183+
--------------------------
184+
185+
The ``StringifiedSymbol`` field type is the recommended field type for storing
186+
values that should be exposed as symbols to Ruby applications. When using the ``Symbol`` field type,
187+
Mongoid defaults to storing values as BSON symbols. For more information on the
188+
BSON symbol type, see :ref:`here <bson-symbol>`.
189+
However, the BSON symbol type is deprecated and is difficult to work with in programming languages
190+
without native symbol types, so the ``StringifiedSymbol`` type allows the use of symbols
191+
while ensuring interoperability with other drivers. The ``StringifiedSymbol`` type stores all data
192+
on the database as strings, while exposing values to the application as symbols.
193+
194+
An example usage is shown below:
195+
196+
.. code-block:: ruby
197+
198+
class Post
199+
include Mongoid::Document
200+
201+
field :status, type: StringifiedSymbol
202+
end
203+
204+
post = Post.new(status: :hello)
205+
# status is stored as "hello" on the database, but returned as a Symbol
206+
post.status
207+
# => :hello
208+
209+
# String values can be assigned also:
210+
post = Post.new(status: "hello")
211+
# status is stored as "hello" on the database, but returned as a Symbol
212+
post.status
213+
# => :hello
214+
215+
All non-string values will be stringified upon being sent to the database (via ``to_s``), and
216+
all values will be converted to symbols when returned to the application. Values that cannot be
217+
converted directly to symbols, such as integers and arrays, will first be converted to strings and
218+
then symbols before being returned to the application.
219+
220+
For example, setting an integer as ``status``:
221+
222+
.. code-block:: ruby
223+
224+
post = Post.new(status: 42)
225+
post.status
226+
# => :"42"
227+
228+
If the ``StringifiedSymbol`` type is applied to a field that contains BSON symbols, the values
229+
will be stored as strings instead of BSON symbols on the next save. This permits transparent lazy
230+
migration from fields that currently store either strings or BSON symbols in the database to the
231+
``StringifiedSymbol`` field type.
179232

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

762+
.. _bson-symbol:
763+
709764
BSON Symbol Type
710765
----------------
711766

712-
Because the BSON specification deprecated the BSON symbol type, the `bson` gem will serialize Ruby
713-
symbols into BSON strings when used on its own. However, in order to maintain backwards
714-
compatibility with older datasets, the `mongo` gem overrides this behavior to serialize Ruby symbols as
715-
BSON symbols. This is necessary to be able to specify queries for documents which contain BSON
716-
symbols as fields.
717-
718-
Although Mongoid allows applications to define fields with the Symbol type, this could present
719-
problems when using other MongoDB tools that have removed support for the type. Because of this,
720-
new applications should not specify model fields with the Symbol type but instead use the String
721-
type. Existing applications with Symbol model fields may convert those fields to Strings using one
722-
of the following methods:
723-
724-
- Eager migration: write a script or rake task that queries each document and
725-
updates any symbols it finds to strings. This will take a long time for larger data sets, and
726-
unless the application can handle two different schemas existing in the data at once, it will
727-
need to be taken offline while the script runs.
728-
729-
- Lazy migration: rather than updating all of the documents at once, each document received from a
730-
query should be reinserted if it contains a symbol; since the BSON received will have already
731-
turned the symbols into strings, this will update the document as necessary. This can be done
732-
by simply calling the ``save`` method. Note that this method will only convert documents that
733-
are accessed by the application, eventually eager migration may be needed to convert the
734-
remaining documents that haven't been accessed.
735-
736-
To override default behaivor and configure the ``mongo`` gem (and thereby Mongoid as well)
737-
to encode symbol values as strings, include the following code snippet in your project:
767+
New applications should use the :ref:`StringifiedSymbol field type <stringified-symbol>`
768+
to store Ruby symbols in the database. The ``StringifiedSymbol`` field type
769+
provides maximum compatibility with other applications and programming languages
770+
and has the same behavior in all circumstances.
771+
772+
Mongoid also provides the deprecated ``Symbol`` field type for serializing
773+
Ruby symbols to BSON symbols. Because the BSON specification deprecated the
774+
BSON symbol type, the `bson` gem will serialize Ruby symbols into BSON strings
775+
when used on its own. However, in order to maintain backwards compatibility
776+
with older datasets, the `mongo` gem overrides this behavior to serialize Ruby
777+
symbols as BSON symbols. This is necessary to be able to specify queries for
778+
documents which contain BSON symbols as fields.
779+
780+
To override the default behavior and configure the ``mongo`` gem (and thereby
781+
Mongoid as well) to encode symbol values as strings, include the following code
782+
snippet in your project:
738783

739784
.. code-block:: ruby
740785

lib/mongoid/extensions.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def transform_keys
3838
require "mongoid/extensions/regexp"
3939
require "mongoid/extensions/set"
4040
require "mongoid/extensions/string"
41+
require "mongoid/stringified_symbol"
4142
require "mongoid/extensions/symbol"
4243
require "mongoid/extensions/time"
4344
require "mongoid/extensions/time_with_zone"

lib/mongoid/fields.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ module Mongoid
1212
module Fields
1313
extend ActiveSupport::Concern
1414

15+
StringifiedSymbol = Mongoid::StringifiedSymbol
16+
1517
# For fields defined with symbols use the correct class.
1618
#
1719
# @since 4.0.0
@@ -30,6 +32,7 @@ module Fields
3032
regexp: Regexp,
3133
set: Set,
3234
string: String,
35+
stringified_symbol: StringifiedSymbol,
3336
symbol: Symbol,
3437
time: Time
3538
}.with_indifferent_access

lib/mongoid/stringified_symbol.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
# encoding: utf-8
3+
4+
# A class which sends values to the database as Strings but returns them to the user as Symbols.
5+
module Mongoid
6+
class StringifiedSymbol
7+
8+
class << self
9+
10+
# Convert the object from its mongo friendly ruby type to this type.
11+
#
12+
# @example Demongoize the object.
13+
# Symbol.demongoize(object)
14+
#
15+
# @param [ Object ] object The object to demongoize.
16+
#
17+
# @return [ Symbol ] The object.
18+
#
19+
# @api private
20+
def demongoize(object)
21+
if object.nil?
22+
object
23+
else
24+
object.to_s.to_sym
25+
end
26+
end
27+
28+
# Turn the object from the ruby type we deal with to a Mongo friendly
29+
# type.
30+
#
31+
# @example Mongoize the object.
32+
# Symbol.mongoize("123.11")
33+
#
34+
# @param [ Object ] object The object to mongoize.
35+
#
36+
# @return [ Symbol ] The object mongoized.
37+
#
38+
# @api private
39+
def mongoize(object)
40+
if object.nil?
41+
object
42+
else
43+
object.to_s
44+
end
45+
end
46+
47+
# @api private
48+
def evolve(object)
49+
mongoize(object)
50+
end
51+
end
52+
end
53+
end
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
require "spec_helper"
2+
3+
describe "StringifiedSymbol fields" do
4+
5+
before do
6+
Order.destroy_all
7+
end
8+
9+
context "when querying the database" do
10+
11+
let!(:document) do
12+
Order.create!(saved_status: :test)
13+
end
14+
15+
let(:string_query) do
16+
{'saved_status' => {'$eq' => 'test'}}
17+
end
18+
19+
let(:symbol_query) do
20+
{'saved_status' => {'$eq' => :test}}
21+
end
22+
23+
it "can be queried with a string" do
24+
doc = Order.where(string_query).first
25+
expect(doc.saved_status).to eq(:test)
26+
end
27+
28+
it "can be queried with a symbol" do
29+
doc = Order.where(symbol_query).first
30+
expect(doc.saved_status).to eq(:test)
31+
end
32+
end
33+
34+
# Using command monitoring to test that StringifiedSymbol sends a string and returns a symbol
35+
let(:client) { Order.collection.client }
36+
37+
before do
38+
client.subscribe(Mongo::Monitoring::COMMAND, subscriber)
39+
end
40+
41+
after do
42+
client.unsubscribe(Mongo::Monitoring::COMMAND, subscriber)
43+
end
44+
45+
let(:subscriber) do
46+
EventSubscriber.new
47+
end
48+
49+
let(:find_events) do
50+
subscriber.started_events.select { |event| event.command_name.to_s == 'find' }
51+
end
52+
53+
let(:insert_events) do
54+
subscriber.started_events.select { |event| event.command_name.to_s == 'insert' }
55+
end
56+
57+
let(:update_events) do
58+
subscriber.started_events.select { |event| event.command_name.to_s == 'update' }
59+
end
60+
61+
before do
62+
subscriber.clear_events!
63+
end
64+
65+
let(:query) do
66+
{'saved_status' => {'$eq' => 'test'}}
67+
end
68+
69+
let!(:document1) do
70+
Order.create!(saved_status: :test)
71+
end
72+
73+
let!(:document2) do
74+
Order.where(query).first
75+
end
76+
77+
context "when inserting document" do
78+
79+
it "sends the value as a string" do
80+
Order.create!(saved_status: :test)
81+
event = insert_events.first
82+
doc = event.command["documents"].first
83+
expect(doc["saved_status"]).to eq("test")
84+
end
85+
86+
it "sends the value as a string" do
87+
Order.create!(saved_status: 42)
88+
event = insert_events.second
89+
doc = event.command["documents"].first
90+
expect(doc["saved_status"]).to eq("42")
91+
end
92+
93+
it "sends the value as a string" do
94+
Order.create(saved_status: [0, 1, 2])
95+
event = insert_events.second
96+
doc = event.command["documents"].first
97+
expect(doc["saved_status"]).to eq("[0, 1, 2]")
98+
end
99+
end
100+
101+
context "when finding document" do
102+
103+
it "receives the value as a symbol" do
104+
event = find_events.first
105+
expect(document2.saved_status).to eq(:test)
106+
end
107+
end
108+
109+
context "when reading a BSON Symbol field" do
110+
111+
before do
112+
client["orders"].insert_one(saved_status: BSON::Symbol::Raw.new("test"), _id: 12)
113+
end
114+
115+
it "receives the value as a symbol" do
116+
expect(Order.find(12).saved_status).to eq(:test)
117+
end
118+
119+
it "saves the value as a string" do
120+
s = Order.find(12)
121+
s.saved_status = :other
122+
s.save!
123+
event = update_events.first
124+
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("other")
125+
end
126+
end
127+
128+
context "when value is nil" do
129+
130+
before do
131+
client["orders"].insert_one(saved_status: nil, _id: 15)
132+
end
133+
134+
it "returns nil" do
135+
expect(Order.find(15).saved_status).to be_nil
136+
end
137+
end
138+
139+
context "when writing nil" do
140+
141+
before do
142+
client["orders"].insert_one(saved_status: "hello", _id: 16)
143+
end
144+
145+
it "saves the value as nil" do
146+
s = Order.find(16)
147+
s.saved_status = nil
148+
s.save!
149+
event = update_events.first
150+
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to be_nil
151+
end
152+
end
153+
154+
context "when reading an integer" do
155+
156+
before do
157+
client["orders"].insert_one(saved_status: 42, _id: 13)
158+
end
159+
160+
it "receives the value as a symbol" do
161+
expect(Order.find(13).saved_status).to eq(:"42")
162+
end
163+
164+
it "saves the value as a string" do
165+
s = Order.find(13)
166+
s.saved_status = 24
167+
s.save!
168+
event = update_events.first
169+
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("24")
170+
end
171+
end
172+
173+
context "when reading an array" do
174+
before do
175+
client["orders"].insert_one(saved_status: [0, 1, 2], _id: 14)
176+
end
177+
178+
it "receives the value as a symbol" do
179+
expect(Order.find(14).saved_status).to be(:"[0, 1, 2]")
180+
end
181+
182+
it "saves the value as a string" do
183+
s = Order.find(14)
184+
s.saved_status = [3, 4, 5]
185+
s.save!
186+
event = update_events.first
187+
expect(event.command["updates"].first["u"]["$set"]["saved_status"]).to eq("[3, 4, 5]")
188+
end
189+
end
190+
end

0 commit comments

Comments
 (0)