Skip to content

Commit

Permalink
Compatibility changes to support mongodb_ecto adapter (#227)
Browse files Browse the repository at this point in the history
* Add BSON encoders for Elixir Date/NaiveDateTime

* Return FindAndModifyResult struct from appropriate operations

* Fix conflation of application `log` env var and function option of the same name

The application env variable called `log` is meant to be either a boolean or atom log level, whereas the function option called `log` is potentially a function or MFA tuple that is passed down to DBConnection.

* Add generic Mongo.update/4 function

This function is copied from the older `mongodb` driver for compatibility with the ecto adapter

* Update tests for functions returning FindAndModifyResult

* Mix format

* Update array_filters test for FindAndModifyResult structs
  • Loading branch information
brennana authored Jan 13, 2024
1 parent 3f3c354 commit 7ffb885
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 17 deletions.
20 changes: 20 additions & 0 deletions lib/bson/encoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ defmodule BSON.Encoder do
<<unix_ms::int64()>>
end

def encode(%Date{} = date) do
unix_ms =
NaiveDateTime.from_erl!({Date.to_erl(date), {0, 0, 0}}, 0, Calendar.ISO)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)

<<unix_ms::int64()>>
end

def encode(%NaiveDateTime{} = datetime) do
unix_ms =
datetime
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)

<<unix_ms::int64()>>
end

def encode(%BSON.Regex{pattern: pattern, options: options}),
do: [cstring(pattern) | cstring(options)]

Expand Down Expand Up @@ -152,6 +170,8 @@ defmodule BSON.Encoder do
defp type(%BSON.Binary{}), do: @type_binary
defp type(%BSON.ObjectId{}), do: @type_objectid
defp type(%DateTime{}), do: @type_datetime
defp type(%NaiveDateTime{}), do: @type_datetime
defp type(%Date{}), do: @type_datetime
defp type(%BSON.Regex{}), do: @type_regex
defp type(%BSON.JavaScript{scope: nil}), do: @type_js
defp type(%BSON.JavaScript{}), do: @type_js_scope
Expand Down
102 changes: 97 additions & 5 deletions lib/mongo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,13 @@ defmodule Mongo do
)

with {:ok, doc} <- issue_command(topology_pid, cmd, :write, opts) do
{:ok, doc["value"]}
{:ok,
%Mongo.FindAndModifyResult{
value: doc["value"],
matched_count: doc["lastErrorObject"]["n"],
updated_existing: doc["lastErrorObject"]["updatedExisting"],
upserted_id: doc["lastErrorObject"]["upserted"]
}}
end
end

Expand Down Expand Up @@ -559,7 +565,15 @@ defmodule Mongo do
~w(bypass_document_validation max_time projection return_document sort upsert collation)a
)

with {:ok, doc} <- issue_command(topology_pid, cmd, :write, opts), do: {:ok, doc["value"]}
with {:ok, doc} <- issue_command(topology_pid, cmd, :write, opts) do
{:ok,
%Mongo.FindAndModifyResult{
value: doc["value"],
matched_count: doc["lastErrorObject"]["n"],
updated_existing: doc["lastErrorObject"]["updatedExisting"],
upserted_id: doc["lastErrorObject"]["upserted"]
}}
end
end

defp should_return_new(:after), do: true
Expand Down Expand Up @@ -1094,6 +1108,86 @@ defmodule Mongo do
bangify(update_many(topology_pid, coll, filter, update, opts))
end

@doc """
Performs one or more update operations.
This function is especially useful for more complex update operations (e.g.
upserting multiple documents). For more straightforward use cases you may
prefer to use these higher level APIs:
* `update_one/5`
* `update_one!/5`
* `update_many/5`
* `update_many!5`
Each update in `updates` may be specified using either the short-hand
Mongo-style syntax (in reference to their docs) or using a long-hand, Elixir
friendly syntax.
See
https://docs.mongodb.com/manual/reference/command/update/#update-statements
e.g. long-hand `query` becomes short-hand `q`, snake case `array_filters`
becomes `arrayFilters`
"""
def update(topology_pid, coll, updates, opts) do
write_concern =
filter_nils(%{
w: Keyword.get(opts, :w),
j: Keyword.get(opts, :j),
wtimeout: Keyword.get(opts, :wtimeout)
})

normalised_updates = updates |> normalise_updates()

cmd =
[
update: coll,
updates: normalised_updates,
ordered: Keyword.get(opts, :ordered),
writeConcern: write_concern,
bypassDocumentValidation: Keyword.get(opts, :bypass_document_validation)
]
|> filter_nils()

with {:ok, doc} <- issue_command(topology_pid, cmd, :write, opts) do
case doc do
%{"writeErrors" => write_errors} ->
{:error, %Mongo.WriteError{n: doc["n"], ok: doc["ok"], write_errors: write_errors}}

%{"n" => n, "nModified" => n_modified} ->
{:ok,
%Mongo.UpdateResult{
matched_count: n,
modified_count: n_modified,
upserted_ids: filter_upsert_ids(doc["upserted"])
}}

%{"ok" => ok} when ok == 1 ->
{:ok, %Mongo.UpdateResult{acknowledged: false}}
end
end
end

defp normalise_updates([[{_, _} | _] | _] = updates) do
updates
|> Enum.map(&normalise_update/1)
end

defp normalise_updates(updates), do: normalise_updates([updates])

defp normalise_update(update) do
update
|> Enum.map(fn
{:query, query} -> {:q, query}
{:update, update} -> {:u, update}
{:updates, update} -> {:u, update}
{:array_filters, array_filters} -> {:arrayFilters, array_filters}
other -> other
end)
|> filter_nils()
end

##
# Calls the update command:
#
Expand Down Expand Up @@ -1804,9 +1898,7 @@ defmodule Mongo do

:telemetry.execute([:mongodb_driver, :execution], %{duration: duration}, metadata)

log = Application.get_env(:mongodb_driver, :log, false)

case Keyword.get(opts, :log, log) do
case Application.get_env(:mongodb_driver, :log, false) do
true ->
Logger.log(:info, fn -> log_iodata(command, collection, params, duration) end, ansi_color: command_color(command))

Expand Down
24 changes: 24 additions & 0 deletions lib/mongo/results.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ defmodule Mongo.UpdateResult do
defstruct acknowledged: true, matched_count: 0, modified_count: 0, upserted_ids: []
end

defmodule Mongo.FindAndModifyResult do
@moduledoc """
The successful result struct of `Mongo.find_one_and_*` functions, which under
the hood use Mongo's `findAndModify` API.
See <https://docs.mongodb.com/manual/reference/command/findAndModify/> for
more information.
"""

@type t :: %__MODULE__{
value: BSON.document(),
matched_count: non_neg_integer(),
upserted_id: String.t(),
updated_existing: boolean()
}

defstruct [
:value,
:matched_count,
:upserted_id,
:updated_existing
]
end

defmodule Mongo.BulkWriteResult do
@moduledoc """
The successful result struct of `Mongo.BulkWrite.write`. Its fields are:
Expand Down
24 changes: 12 additions & 12 deletions test/mongo_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ defmodule Mongo.Test do
assert {:ok, _} = Mongo.insert_one(c.pid, coll, %{foo: 42, bar: 1})

# defaults
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 2}})
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 2}})
assert %{"bar" => 1} = value, "Should return original document by default"

# should raise if we don't have atomic operators
Expand All @@ -232,31 +232,31 @@ defmodule Mongo.Test do
end

# return_document = :after
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 3}}, return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 3}}, return_document: :after)
assert %{"bar" => 3} = value, "Should return modified doc"

# projection
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 3}}, projection: %{"bar" => 1})
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{bar: 3}}, projection: %{"bar" => 1})
assert Map.get(value, "foo") == nil, "Should respect the projection"

# sort
assert {:ok, _} = Mongo.insert_one(c.pid, coll, %{foo: 42, bar: 10})
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{baz: 1}}, sort: %{"bar" => -1}, return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 42}, %{"$set" => %{baz: 1}}, sort: %{"bar" => -1}, return_document: :after)
assert %{"bar" => 10, "baz" => 1} = value, "Should respect the sort"

# upsert
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 43}, %{"$set" => %{baz: 1}}, upsert: true, return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 43}, %{"$set" => %{baz: 1}}, upsert: true, return_document: :after)
assert %{"foo" => 43, "baz" => 1} = value, "Should upsert"

# array_filters
assert {:ok, _} = Mongo.insert_one(c.pid, coll, %{foo: 44, things: [%{id: "123", name: "test"}, %{id: "456", name: "not test"}]})
assert {:ok, value} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 44}, %{"$set" => %{"things.$[sub].name" => "new"}}, array_filters: [%{"sub.id" => "123"}], return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_update(c.pid, coll, %{"foo" => 44}, %{"$set" => %{"things.$[sub].name" => "new"}}, array_filters: [%{"sub.id" => "123"}], return_document: :after)
assert %{"foo" => 44, "things" => [%{"id" => "123", "name" => "new"}, %{"id" => "456", "name" => "not test"}]} = value, "Should leverage array filters"

# don't find return {:ok, nil}
assert {:ok, nil} == Mongo.find_one_and_update(c.pid, coll, %{"number" => 666}, %{"$set" => %{title: "the number of the beast"}})
assert {:ok, %Mongo.FindAndModifyResult{matched_count: 0, updated_existing: false, value: nil}} == Mongo.find_one_and_update(c.pid, coll, %{"number" => 666}, %{"$set" => %{title: "the number of the beast"}})

assert {:ok, nil} == Mongo.find_one_and_update(c.pid, "coll_that_doesnt_exist", %{"number" => 666}, %{"$set" => %{title: "the number of the beast"}})
assert {:ok, %Mongo.FindAndModifyResult{matched_count: 0, updated_existing: false, value: nil}} == Mongo.find_one_and_update(c.pid, "coll_that_doesnt_exist", %{"number" => 666}, %{"$set" => %{title: "the number of the beast"}})

# wrong parameter
assert {:error, %Mongo.Error{}} = Mongo.find_one_and_update(c.pid, 2, %{"number" => 666}, %{"$set" => %{title: "the number of the beast"}})
Expand All @@ -272,18 +272,18 @@ defmodule Mongo.Test do
end

# defaults
assert {:ok, value} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 42}, %{bar: 2})
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 42}, %{bar: 2})
assert %{"foo" => 42, "bar" => 1} = value, "Should return original document by default"

# return_document = :after
assert {:ok, _} = Mongo.insert_one(c.pid, coll, %{foo: 43, bar: 1})
assert {:ok, value} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 43}, %{bar: 3}, return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 43}, %{bar: 3}, return_document: :after)
assert %{"bar" => 3} = value, "Should return modified doc"
assert match?(%{"foo" => 43}, value) == false, "Should replace document"

# projection
assert {:ok, _} = Mongo.insert_one(c.pid, coll, %{foo: 44, bar: 1})
assert {:ok, value} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 44}, %{foo: 44, bar: 3}, return_document: :after, projection: %{bar: 1})
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_replace(c.pid, coll, %{"foo" => 44}, %{foo: 44, bar: 3}, return_document: :after, projection: %{bar: 1})
assert Map.get(value, "foo") == nil, "Should respect the projection"

# sort
Expand All @@ -295,7 +295,7 @@ defmodule Mongo.Test do

# upsert
assert [] = Mongo.find(c.pid, coll, %{upsertedDocument: true}) |> Enum.to_list()
assert {:ok, value} = Mongo.find_one_and_replace(c.pid, coll, %{"upsertedDocument" => true}, %{"upsertedDocument" => true}, upsert: true, return_document: :after)
assert {:ok, %Mongo.FindAndModifyResult{value: value}} = Mongo.find_one_and_replace(c.pid, coll, %{"upsertedDocument" => true}, %{"upsertedDocument" => true}, upsert: true, return_document: :after)
assert %{"upsertedDocument" => true} = value, "Should upsert"
assert [%{"upsertedDocument" => true}] = Mongo.find(c.pid, coll, %{upsertedDocument: true}) |> Enum.to_list()
end
Expand Down

0 comments on commit 7ffb885

Please sign in to comment.