Skip to content

Commit b360d87

Browse files
zookzookrhcarvalho
andauthored
fix: preserve the order of the keys (#211)
* fix: preserve the order of the keys Co-authored-by: Rodolfo Carvalho <rhcarvalho@gmail.com>
1 parent f01d497 commit b360d87

File tree

5 files changed

+456
-199
lines changed

5 files changed

+456
-199
lines changed

README.md

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,18 @@ Mongo.insert_many(top, "users", [
135135

136136
## Data Representation
137137

138-
This driver chooses to accept both maps and lists of key-value tuples when encoding BSON documents (1), but will only decode documents into maps. This has the side effect that document field order is lost when decoding. Maps are convenient to work with, but map keys are not ordered, unlike BSON document fields.
138+
This driver chooses to accept both maps and lists of key-value tuples when encoding BSON documents (1), but will only
139+
decode documents into maps. Maps are convenient to work with, but Elixir map keys are not ordered, unlike BSON document
140+
keys.
139141

140-
Driver users should represent documents using a list of tuples when field order matters, for example when sorting by multiple fields:
142+
That design decision means document key order is lost when encoding Elixir maps to BSON and, conversely, when decoding
143+
BSON documents to Elixir maps. However, see [Preserve Document Key Order](#preserve-document-key-order) to learn how to
144+
preserve key order when it matters.
141145

142-
```elixir
143-
Mongo.find(top, "users", %{}, sort: [last_name: 1, first_name: 1, _id: 1])
144-
```
145-
146-
Additionally, the driver accepts both atoms and strings for document keys, but will only decode them into strings. Creating atoms from arbitrary input (such as database documents) is [discouraged](https://elixir-lang.org/getting-started/mix-otp/genserver.html#:~:text=However%2C%20naming%20dynamic,our%20system%20memory!) because atoms are not garbage collected.
146+
Additionally, the driver accepts both atoms and strings for document keys, but will only decode them into strings.
147+
Creating atoms from arbitrary input (such as database documents) is
148+
[discouraged](https://elixir-lang.org/getting-started/mix-otp/genserver.html#:~:text=However%2C%20naming%20dynamic,our%20system%20memory!)
149+
because atoms are not garbage collected.
147150

148151
[BSON symbols (deprecated)](https://bsonspec.org/spec.html#:~:text=Symbol.%20%E2%80%94%20Deprecated) can only be decoded (2).
149152

@@ -169,6 +172,81 @@ Additionally, the driver accepts both atoms and strings for document keys, but w
169172
max key :BSON_max
170173
decimal128 Decimal{}
171174

175+
## Preserve Document Key Order
176+
177+
### Encoding from Elixir to BSON
178+
179+
For some MongoDB operations, the order of the keys in a document affect the result. For example, that is the case when
180+
sorting a query by multiple fields.
181+
182+
In those cases, driver users should represent documents using a list of tuples (or a keyword list) to preserve the
183+
order. Example:
184+
185+
```elixir
186+
Mongo.find(top, "users", %{}, sort: [last_name: 1, first_name: 1, _id: 1])
187+
```
188+
189+
The query above will sort users by last name, then by first name and finally by ID. If an Elixir map had been used to
190+
specify `:sort`, query results would end up sorted unexpectedly wrong.
191+
192+
### Decoding from BSON to Elixir
193+
194+
Decoded BSON documents are always represented by Elixir maps because the driver depends on that to implement its
195+
functionality.
196+
197+
If the order of document keys as stored by MongoDB is needed, the driver can be configured to use a BSON decoder module
198+
that puts a list of keys in the original order under the `:__order__` key (and it works recursively).
199+
200+
```elixir
201+
config :mongodb_driver,
202+
decoder: BSON.PreserveOrderDecoder
203+
```
204+
205+
It is possible to customize the key. For example, to use `:original_order` instead of the default `:__order__`:
206+
207+
```elixir
208+
config :mongodb_driver,
209+
decoder: {BSON.PreserveOrderDecoder, key: :original_order}
210+
```
211+
212+
The resulting maps with annotated key order can be recursively transformed into lists of tuples. That allows for
213+
preserving the order again when encoding. Here is an example of how to achieve that:
214+
215+
```elixir
216+
defmodule MapWithOrder do
217+
def to_list(doc, order_key \\ :__order__) do
218+
do_to_list(doc, order_key)
219+
end
220+
221+
defp do_to_list(%{__struct__: _} = elem, _order_key) do
222+
elem
223+
end
224+
225+
defp do_to_list(doc, order_key) when is_map(doc) do
226+
doc
227+
|> Map.get(order_key, Map.keys(doc))
228+
|> Enum.map(fn key -> {key, do_to_list(Map.get(doc, key), order_key)} end)
229+
end
230+
231+
defp do_to_list(xs, order_key) when is_list(xs) do
232+
Enum.map(xs, fn elem -> do_to_list(elem, order_key) end)
233+
end
234+
235+
defp do_to_list(elem, _order_key) do
236+
elem
237+
end
238+
end
239+
240+
# doc = ...
241+
MapWithOrder.to_list(doc)
242+
```
243+
244+
Note that structs are kept as-is, to handle special values such as `BSON.ObjectId`.
245+
246+
The decoder module is defined at compile time. The default decoder is `BSON.Decoder`, which does not preserve document
247+
key order. As it needs to execute fewer operations when decoding data received from MongoDB, it offers improved
248+
performance. Therefore, the default decoder is recommended for most use cases of this driver.
249+
172250
## Writing your own encoding info
173251

174252
If you want to write a custom struct to your mongo collection - you can do that

0 commit comments

Comments
 (0)