From f33718a15cb8f3eaae3172c4a32bbdac6158076e Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 27 Jun 2023 14:53:57 -0600 Subject: [PATCH 1/7] Add BSON encoders for Elixir Date/NaiveDateTime --- lib/bson/encoder.ex | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/bson/encoder.ex b/lib/bson/encoder.ex index 38b9c863..3c12db61 100644 --- a/lib/bson/encoder.ex +++ b/lib/bson/encoder.ex @@ -36,6 +36,24 @@ defmodule BSON.Encoder do <> 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) + + <> + end + + def encode(%NaiveDateTime{} = datetime) do + unix_ms = + datetime + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix(:millisecond) + + <> + end + def encode(%BSON.Regex{pattern: pattern, options: options}), do: [cstring(pattern) | cstring(options)] @@ -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 From 5432919fba109092864706eac18f403e9e995173 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 27 Jun 2023 15:05:52 -0600 Subject: [PATCH 2/7] Return FindAndModifyResult struct from appropriate operations --- lib/mongo.ex | 18 ++++++++++++++++-- lib/mongo/results.ex | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/mongo.ex b/lib/mongo.ex index b368ebbb..94556e3b 100644 --- a/lib/mongo.ex +++ b/lib/mongo.ex @@ -504,7 +504,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 @@ -560,7 +566,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 diff --git a/lib/mongo/results.ex b/lib/mongo/results.ex index 03a223b1..a369a848 100644 --- a/lib/mongo/results.ex +++ b/lib/mongo/results.ex @@ -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 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: From 2b0a6d5f243cc9d1b59c3a8def3e066b0b9c36d9 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 27 Jun 2023 15:09:55 -0600 Subject: [PATCH 3/7] 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. --- lib/mongo.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/mongo.ex b/lib/mongo.ex index 94556e3b..08e29ec4 100644 --- a/lib/mongo.ex +++ b/lib/mongo.ex @@ -1819,9 +1819,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)) From 8d97ec70302b4913b447e0a42bb2dfb3983db255 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 27 Jun 2023 15:11:52 -0600 Subject: [PATCH 4/7] Add generic Mongo.update/4 function This function is copied from the older `mongodb` driver for compatibility with the ecto adapter --- lib/mongo.ex | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/lib/mongo.ex b/lib/mongo.ex index 08e29ec4..0cd7c41c 100644 --- a/lib/mongo.ex +++ b/lib/mongo.ex @@ -1109,6 +1109,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: # From 15d26a4a01ee0a8be1aeefada62ddc003827c2c2 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 27 Jun 2023 15:31:36 -0600 Subject: [PATCH 5/7] Update tests for functions returning FindAndModifyResult --- test/mongo_test.exs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/mongo_test.exs b/test/mongo_test.exs index 024e66da..15fa8219 100644 --- a/test/mongo_test.exs +++ b/test/mongo_test.exs @@ -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 @@ -232,26 +232,26 @@ 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" # 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"}}) @@ -267,18 +267,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 @@ -290,7 +290,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 From 4ffd4f6c4922048c1d1828d8d7a3d4cd31a8e4c3 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 9 Jan 2024 11:20:11 -0700 Subject: [PATCH 6/7] Mix format --- lib/bson/encoder.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bson/encoder.ex b/lib/bson/encoder.ex index b0875443..63c4b686 100644 --- a/lib/bson/encoder.ex +++ b/lib/bson/encoder.ex @@ -42,7 +42,7 @@ defmodule BSON.Encoder do |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix(:millisecond) - <> + <> end def encode(%NaiveDateTime{} = datetime) do @@ -51,7 +51,7 @@ defmodule BSON.Encoder do |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_unix(:millisecond) - <> + <> end def encode(%BSON.Regex{pattern: pattern, options: options}), From bae2444b0f1ac35f5ae3522fcce6d974296774f5 Mon Sep 17 00:00:00 2001 From: Andy Brennan Date: Tue, 9 Jan 2024 11:28:16 -0700 Subject: [PATCH 7/7] Update array_filters test for FindAndModifyResult structs --- test/mongo_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mongo_test.exs b/test/mongo_test.exs index 1c2e638f..ed4d59e0 100644 --- a/test/mongo_test.exs +++ b/test/mongo_test.exs @@ -250,7 +250,7 @@ defmodule Mongo.Test do # 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}