defmodule Mongo.Collection do
@moduledoc """
This module provides some boilerplate code for a better support of structs while using the
MongoDB driver:
* automatic load and dump function
* reflection functions
* type specification
* support for embedding one and many structs
* support for `after load` function
* support for `before dump` function
* support for id generation
* support for default values
* support for derived values
When using the MongoDB driver only maps and keyword lists are used to
represent documents.
If you prefer to use structs instead of the maps to give the document a stronger meaning or to emphasize
its importance, you have to create a `defstruct` and fill it from the map manually:
defmodule Label do
defstruct name: "warning", color: "red"
end
iex> label_map = Mongo.find_one(:mongo, "labels", %{})
%{"name" => "warning", "color" => "red"}
iex> label = %Label{name: label_map["name"], color: label_map["color"]}
We have defined a module `Label` as `defstruct`, then we get the first label document
the collection `labels`. The function `find_one` returns a map. We convert the map manually and
get the desired struct.
If we want to save a new structure, we have to do the reverse. We convert the struct into a map:
iex> label = %Label{}
iex> label_map = %{"name" => label.name, "color" => label.color}
iex> {:ok, _} = Mongo.insert_one(:mongo, "labels", label_map)
Alternatively, you can also remove the `__struct__` key from `label`. The MongoDB driver automatically
converts the atom keys into strings.
iex> Map.drop(label, [:__struct__])
%{color: :red, name: "warning"}
If you use nested structures, the work becomes a bit more complex. In this case, you have to use the inner structures
convert manually, too.
If you take a closer look at the necessary work, two basic functions can be derived:
* `load` Conversion of the map into a struct.
* `dump` Conversion of the struct into a map.
This module provides the necessary macros to automate this boilerplate code.
The above example can be rewritten as follows:
defmodule Label do
use Collection
document do
attribute :name, String.t(), default: "warning"
attribute :color, String.t(), default: :red
end
end
This results in the following module:
defmodule Label do
defstruct [name: "warning", color: "red"]
@type t() :: %Label{String.t(), String.t()}
def new()...
def load(map)...
def dump(%Label{})...
def __collection__(:attributes)...
def __collection__(:types)...
def __collection__(:collection)...
def __collection__(:id)...
end
You can now create new structs with the default values and use the conversion functions between maps and
structs:
iex(1)> x = Label.new()
%Label{color: :red, name: "warning"}
iex(2)> m = Label.dump(x)
%{color: :red, name: "warning"}
iex(3)> Label.load(m, true)
%Label{color: :red, name: "warning"}
The `load/2` function distinguishes between keys of type binaries `load(map, false)` and keys of type atoms `load(map, true)`.
The default is `load(map, false)`:
iex(1)> m = %{"color" => :red, "name" => "warning"}
iex(2)> Label.load(m)
%Label{color: :red, name: "warning"}
If you would now expect atoms as keys, the result of the conversion is not correct in this case:
iex(3)> Label.load(m, true)
%Label{color: nil, name: nil}
The background is that MongoDB always returns binaries as keys and structs use atoms as keys.
## Dump and load function
Using the collection modules increases the cpu usage because we need to
serialize the map into the struct if we load the document from the database.
And if we want to save the struct to the database, the dump function is called,
that serializes the struct into a map. Both functions take care about the key
mapping as well. What do the generated functions look like in detail?
Assuming we have the following collections defined:
defmodule Label do
use Collection
document do
attribute :name, String.t(), default: "warning"
end
end
defmodule User do
use Collection
document do
attribute :first_name, String.t()
attribute :last_name, String.t()
attribute :email, String.t()
embeds_one :label, Label
end
end
The collection module creates the load function like this:
Map.put(%{
first_name: Map.get(user, "first_name"),
last_name: Map.get(user, "last_name"),
email: Map.get(user, "email"),
label: Label.load(Map.get(user, "label"))
}, :__struct__, User)
The performance depends on the number of attributes and embedded structs. It seems, that
using a map first is the fasted way to create a new struct. A few variations were tried out:
new quoted load 2.43 M
load manually created using Map.get 2.25 M - 1.08x slower +32.91 ns
load manually created using [] 2.12 M - 1.15x slower +60.34 ns
original load 0.82 M - 2.96x slower +805.70 ns
The dump function looks like this:
[
{"first_name", user.first_name},
{"last_name", user.last_name},
{"email", user.email},
{"label", Label.dump(user.label)}
]
|> Enum.filter(fn {_key, value} -> value != nil end)
|> Map.new()
The load and dump function uses only the defined attributes and embedded structs.
Unknown attributes are ignored.
## Default and derived values
Attributes have two options:
* `default:` a value or a function, which is called, when a new struct is created
* `derived:` `true` to indicate, that is attribute should not be saved to the database
If you call `new/0` a new struct is returned filled with the default values. In case of a function the
function is called to use the return value as default.
attribute: created, DateTime.t(), &DateTime.utc_now/0
If you mark an attribute as a derived attribute (`derived: true`) then the dump function will remove
the attributes from the struct automatically for you, so these kind of attributes won't be saved in
the database.
attribute :id, String.t(), derived: true
## Key mapping
It is possible to define a different name for the keys. The name of the key is defined by using the `:name` option.
attribute :very_long_name, String.t(), name: v
The dump and load functions will use the name `v` instead of `very_long_name`:
defmodule Label do
use Mongo.Collection
document do
attribute :very_long_attribute, String.t(), name: :v
end
end
iex > l = %Label{very_long_attribute: "Hello"}
%Label{very_long_attribute: "Hello"}
iex > Label.dump(l)
%{"v" => "Hello"}
iex > dumped_l = Label.dump(l)
%{"v" => "Hello"}
iex > Label.load(dumped_l)
%Label{very_long_attribute: "Hello"}
You can reduce the size of the collection by using short attribute-names or use the `:name` option to import the
documents for a migration.
## Collections
In MongoDB, documents are written in collections. We can use the `collection/2` macro to create
a collection:
defmodule Card do
use Collection
collection "cards" do
attribute :title, String.t(), default: "new title"
end
end
The `collection/2` macro creates a collection that is basically similar to a document, where
an attribute for the ID is added automatically. Additionally, the attribute `@collection` is assigned and
can be used as a constant in other functions.
In the example above we only suppress a warning of the editor by `@collection`. The macro creates the following
expression: `@collection "cards"`. By default, the following attribute is created for the ID:
{:_id, BSON.ObjectId.t(), &Mongo.object_id/0}
where the default value is created via the function `&Mongo.object_id/0` when calling `new/0`:
iex> Card.new()
%Card{_id: #BSON.ObjectId<5ec3d04a306a5f296448a695>, title: "new title"}
Two additional reflection features are also provided:
iex> Card.__collection__(:id)
:_id
iex(3)> Card.__collection__(:collection)
"cards"
## MongoDB example
We define the following collection:
defmodule Card do
use Collection
@collection nil ## keeps the editor happy
@id nil
collection "cards" do
attribute :title, String.t(), default: "new title"
end
def insert_one(%Card{} = card) do
with map <- dump(card),
{:ok, _} <- Mongo.insert_one(:mongo, @collection, map) do
:ok
end
end
def find_one(id) do
:mongo
|> Mongo.find_one(@collection, %{@id => id})
|> load()
end
end
Then we can call the functions `insert_one` and `find_one`. Thereby
we always use the defined structs as parameters or get the
struct as result:
iex(1)> card = Card.new()
%Card{_id: #BSON.ObjectId<5ec3ed0d306a5f377943c23c>, title: "new title"}
iex(6)> Card.insert_one(card)
:ok
iex(2)> Card.find_one(card._id)
%XCard{_id: #BSON.ObjectId<5ec3ecbf306a5f3779a5edaa>, title: "new title"}
## ID generator
In MongoDB it is common to use the attribute `_id` as id. The value is
uses an ObjectId generated by the mongodb driver. This behavior can be specified by
the module attribute `@id_generator` when using `collection`.
The default setting is
{:_id, BSON.ObjectId.t(), &Mongo.object_id/0}
Now you can overwrite this tuple `{name, type, function}` as you like:
@id_generator false # no ID creation
@id_generator {id, String.t, &IDGenerator.next()/0} # customized name and generator
@id_generator nil # use default: {:_id, BSON.ObjectId.t(), &Mongo.object_id/0}
### Embedded documents
Until now we had only shown simple attributes. It will only be interesting when we
embed other structs. With the macros `embeds_one/3` and `embeds_many/3`, structs can be
added to the attributes:
## Example `embeds_one`
defmodule Label do
use Collection
document do
attribute :name, String.t(), default: "warning"
attribute :color, String.t(), default: :red
end
end
defmodule Card do
use Collection
collection "cards" do
attribute :title, String.t()
attribute :list, BSON.ObjectId.t()
attribute :created, DateString.t(), default: &DateTime.utc_now/0
attribute :modified, DateString.t(), default: &DateTime.utc_now/0
embeds_one :label, Label, default: &Label.new/0
end
end
If we now call `new/0`, we get the following structure:
iex(1)> Card.new()
%Card{
_id: #BSON.ObjectId<5ec3f0f0306a5f3aa5418a24>,
created: ~U[2020-05-19 14:45:04.141044Z],
label: %Label{color: :red, name: "warning"},
list: nil,
modified: ~U[2020-05-19 14:45:04.141033Z],
title: nil
}
## `after_load/1` and `before_dump/1` macros
Sometimes you may want to perform post-processing after loading the data set, for example
to create derived attributes. Conversely, before saving, you may want to
drop the derived attributes so that they are not saved to the database.
For this reason there are two macros `after_load/1` and `before_dump/1`. You can
specify functions that are called after the `load/0` or before the `dump`:
## Example `embeds_many`
defmodule Board do
use Collection
collection "boards" do
attribute :id, String.t() ## derived attribute
attribute :title, String.t()
attribute :created, DateString.t(), default: &DateTime.utc_now/0
attribute :modified, DateString.t(), default: &DateTime.utc_now/0
embeds_many :lists, BoardList
after_load &Board.after_load/1
before_dump &Board.before_dump/1
end
def after_load(%Board{_id: id} = board) do
%Board{board | id: BSON.ObjectId.encode!(id)}
end
def before_dump(board) do
%Board{board | id: nil}
end
def new(title) do
new()
|> Map.put(:title, title)
|> Map.put(:lists, [])
|> after_load()
end
def store(board) do
with map <- dump(board),
{:ok, _} <- Mongo.insert_one(:mongo, @collection, map) do
:ok
end
end
def fetch(id) do
:mongo
|> Mongo.find_one(@collection, %{@id => id})
|> load()
end
end
In this example the attribute `id` is derived from the actual ID and stored as a binary.
This attribute is often used and therefore we want to save the conversion of the ID.
To avoid storing the derived attribute `id`, the `before_dump/1` function is called, which
removes the `id` from the struct:
iex(1)> board = Board.new("Vega")
%Board{
_id: #BSON.ObjectId<5ec3f802306a5f3ee3b71cf2>,
created: ~U[2020-05-19 15:15:14.374556Z],
id: "5ec3f802306a5f3ee3b71cf2",
lists: [],
modified: ~U[2020-05-19 15:15:14.374549Z],
title: "Vega"
}
iex(2)> Board.store(board)
:ok
iex(3)> Board.fetch(board._id)
%Board{
_id: #BSON.ObjectId<5ec3f802306a5f3ee3b71cf2>,
created: ~U[2020-05-19 15:15:14.374Z],
id: "5ec3f802306a5f3ee3b71cf2",
lists: [],
modified: ~U[2020-05-19 15:15:14.374Z],
title: "Vega"
}
If we call the document in the Mongo shell, we see that the attribute `id` was not stored there:
> db.boards.findOne({"_id" : ObjectId("5ec3f802306a5f3ee3b71cf2")})
{
"_id" : ObjectId("5ec3f802306a5f3ee3b71cf2"),
"created" : ISODate("2020-05-19T15:15:14.374Z"),
"lists" : [ ],
"modified" : ISODate("2020-05-19T15:15:14.374Z"),
"title" : "Vega"
}
## Example `timestamps`
defmodule Post do
use Mongo.Collection
collection "posts" do
attribute :title, String.t()
timestamps()
end
def new(title) do
Map.put(new(), :title, title)
end
def store(post) do
MyRepo.insert_or_update(post)
end
end
In this example the macro `timestamps` is used to create two DateTime attributes, `inserted_at` and `updated_at`.
This macro is intented to use with the Repo module, as it will be responsible for updating the value of `updated_at` attribute before execute the action.
iex(1)> post = Post.new("lorem ipsum dolor sit amet")
%Post{
_id: #BSON.ObjectId<6327a7099626f7f61607e179>,
inserted_at: ~U[2022-09-18 23:17:29.087092Z],
title: "lorem ipsum dolor sit amet",
updated_at: ~U[2022-09-18 23:17:29.087070Z]
}
iex(2)> Post.store(post)
{:ok,
%{
_id: #BSON.ObjectId<6327a7099626f7f61607e179>,
inserted_at: ~U[2022-09-18 23:17:29.087092Z],
title: "lorem ipsum dolor sit amet",
updated_at: ~U[2022-09-18 23:19:24.516648Z]
}}
Is possible to change the field names, like Ecto does, and also change the default behaviour:
defmodule Comment do
use Mongo.Collection
collection "comments" do
attribute :text, String.t()
timestamps(inserted_at: :created, updated_at: :modified, default: &__MODULE__.truncated_date_time/0)
end
def new(text) do
Map.put(new(), :text, text)
end
def truncated_date_time() do
utc_now = DateTime.utc_now()
DateTime.truncate(utc_now, :second)
end
end
iex(1)> comment = Comment.new("useful comment")
%Comment{
_id: #BSON.ObjectId<6327aa979626f7f616ae5b4a>,
created: ~U[2022-09-18 23:32:39Z],
modified: ~U[2022-09-18 23:32:39Z],
text: "useful comment"
}
iex(2)> {:ok, comment} = MyRepo.insert(comment)
{:ok,
%Comment{
_id: #BSON.ObjectId<6327aa979626f7f616ae5b4a>,
created: ~U[2022-09-18 23:32:39Z],
modified: ~U[2022-09-18 23:32:42Z],
text: "useful comment"
}}
iex(3)> {:ok, comment} = MyRepo.update(%{comment | text: "not so useful comment"})
{:ok,
%Comment{
_id: #BSON.ObjectId<6327aa979626f7f616ae5b4a>,
created: ~U[2022-09-18 23:32:39Z],
modified: ~U[2022-09-18 23:32:46Z],
text: not so useful comment"
}}
The `timestamps` macro has some limitations as it does not run in batch commands like `insert_all` or `update_all`, nor does it update embedded documents.
"""
@type t() :: struct()
alias Mongo.Collection
@doc false
defmacro __using__(_) do
quote do
@before_dump_fun &Function.identity/1
@after_load_fun &Function.identity/1
@id_generator nil
import Collection, only: [document: 1, collection: 2]
Module.register_attribute(__MODULE__, :attributes, accumulate: true)
Module.register_attribute(__MODULE__, :derived, accumulate: true)
Module.register_attribute(__MODULE__, :timestamps, accumulate: true)
Module.register_attribute(__MODULE__, :types, accumulate: true)
Module.register_attribute(__MODULE__, :embed_ones, accumulate: true)
Module.register_attribute(__MODULE__, :embed_manys, accumulate: true)
Module.register_attribute(__MODULE__, :after_load_fun, [])
Module.register_attribute(__MODULE__, :before_dump_fun, [])
end
end
@doc """
Defines a struct as a collection with id generator and a collection.
Inside a collection block, each attribute is defined through the `attribute/3` macro.
"""
defmacro collection(name, do: block) do
make_collection(name, block)
end
@doc """
Defines a struct as a document without id generator and a collection. These documents
are used to be embedded within collection structs.
Inside a document block, each attribute is defined through the `attribute/3` macro.
"""
defmacro document(do: block) do
make_collection(nil, block)
end
defp make_collection(name, block) do
prelude =
quote do
@collection unquote(name)
@id_generator (case @id_generator do
nil -> {:_id, quote(do: BSON.ObjectId.t()), &Mongo.object_id/0}
false -> {nil, nil, nil}
other -> other
end)
@id elem(@id_generator, 0)
Collection.__id__(@id_generator, @collection)
try do
import Collection
unquote(block)
after
:ok
end
end
postlude =
quote unquote: false do
attribute_names = @attributes |> Enum.reverse() |> Enum.map(&elem(&1, 0))
struct_attrs =
(@attributes |> Enum.reverse() |> Enum.map(fn {name, opts} -> {name, opts[:default]} end)) ++
(@embed_ones |> Enum.map(fn {name, _mod, opts} -> {name, opts[:default]} end)) ++
(@embed_manys |> Enum.map(fn {name, _mod, opts} -> {name, opts[:default]} end))
defstruct struct_attrs
Collection.__type__(@types)
def __collection__(:attributes), do: unquote(attribute_names)
def __collection__(:timestamps), do: unquote(@timestamps)
def __collection__(:types), do: @types
def __collection__(:collection), do: unquote(@collection)
def __collection__(:id), do: unquote(elem(@id_generator, 0))
end
new_function =
quote unquote: false do
embed_ones = @embed_ones |> Enum.map(fn {name, _mod, opts} -> {name, opts} end)
embed_manys = @embed_manys |> Enum.map(fn {name, _mod, opts} -> {name, opts} end)
args =
(@attributes ++ embed_ones ++ embed_manys)
|> Enum.map(fn {name, opts} -> {name, opts[:default]} end)
|> Enum.filter(fn {_name, fun} -> is_function(fun) end)
def new() do
case @timestamps != [] do
true ->
new_timestamps(%__MODULE__{unquote_splicing(Collection.struct_args(args))})
false ->
%__MODULE__{unquote_splicing(Collection.struct_args(args))}
end
end
end
load_function =
quote unquote: false do
attrs_mapping =
@attributes
|> Enum.map(fn {name, opts} -> {name, opts[:name]} end)
|> Enum.map(fn {name, {_, src_name}} -> {name, src_name} end)
embeds_mapping =
(@embed_ones ++ @embed_manys)
|> Enum.filter(fn {_name, mod, _opts} -> Collection.has_load_function?(mod) end)
|> Enum.map(fn {name, mod, opts} -> {name, mod, opts[:name]} end)
|> Enum.map(fn {name, mod, {_, src_name}} -> {mod, name, src_name} end)
load_quoted_binaries = Collection.compile_load_steps(attrs_mapping ++ embeds_mapping, __MODULE__, false)
attrs_mapping =
@attributes
|> Enum.map(fn {name, opts} -> {name, opts[:name]} end)
|> Enum.map(fn {name, {src_name, _}} -> {name, src_name} end)
embeds_mapping =
(@embed_ones ++ @embed_manys)
|> Enum.filter(fn {_name, mod, _opts} -> Collection.has_load_function?(mod) end)
|> Enum.map(fn {name, mod, opts} -> {name, mod, opts[:name]} end)
|> Enum.map(fn {name, mod, {src_name, _}} -> {mod, name, src_name} end)
load_quoted_atoms = Collection.compile_load_steps(attrs_mapping ++ embeds_mapping, __MODULE__, true)
def load(_src_doc, _use_atoms \\ false)
def load(nil, _use_atoms) do
nil
end
def load(xs, use_atoms) when is_list(xs) do
Enum.map(xs, fn src_doc -> load(src_doc, use_atoms) end)
end
def load(var!(src_doc), false) do
@after_load_fun.(unquote(load_quoted_binaries))
end
def load(var!(src_doc), true) do
@after_load_fun.(unquote(load_quoted_atoms))
end
end
dump_function =
quote unquote: false do
attrs_mapping =
@attributes
|> Enum.reject(fn {name, _opts} -> Enum.member?(@derived, name) end)
|> Enum.map(fn {name, opts} -> {name, opts[:name]} end)
|> Enum.map(fn {name, {_, src_name}} -> {name, src_name} end)
embeds_mapping =
(@embed_ones ++ @embed_manys)
|> Enum.reject(fn {name, _mod, _opts} -> Enum.member?(@derived, name) end)
|> Enum.filter(fn {_name, mod, _opts} -> Collection.has_load_function?(mod) end)
|> Enum.map(fn {name, mod, opts} -> {name, mod, opts[:name]} end)
|> Enum.map(fn {name, mod, {_, src_name}} -> {mod, name, src_name} end)
dump_quoted_binaries = Collection.compile_dump_steps(attrs_mapping ++ embeds_mapping)
def dump(nil) do
nil
end
def dump(xs) when is_list(xs) do
Enum.map(xs, fn struct -> dump(struct) end)
end
def dump(map) do
var!(src_doc) = @before_dump_fun.(map)
unquote(dump_quoted_binaries)
end
def dump_part(map) do
map
end
end
timestamps_function =
quote unquote: false do
def timestamps(nil), do: nil
def timestamps(xs) when is_list(xs), do: Enum.map(xs, fn struct -> timestamps(struct) end)
def timestamps(struct) do
updated_at = @timestamps[:updated_at]
Collection.timestamps(struct, updated_at, @attributes[updated_at])
end
def new_timestamps(struct) do
inserted_at = @timestamps[:inserted_at]
opts = @attributes[inserted_at]
ts = opts[:default].()
updated_at = @timestamps[:updated_at]
struct
|> Map.put(inserted_at, ts)
|> Map.put(updated_at, ts)
end
end
quote do
unquote(prelude)
unquote(postlude)
unquote(new_function)
unquote(load_function)
unquote(dump_function)
unquote(timestamps_function)
end
end
@doc """
Inserts name option for the attribute, embeds_one and embeds_many.
"""
def add_name(mod, opts, name) do
opts =
case opts[:name] do
nil ->
Keyword.put(opts, :name, {name, to_string(name)})
name when is_atom(name) ->
Keyword.replace(opts, :name, {name, to_string(name)})
name when is_binary(name) ->
Keyword.replace(opts, :name, {String.to_atom(name), name})
_ ->
raise ArgumentError, "name must be an atom or a binary"
end
{src_name, _} = opts[:name]
case name_exists?(mod, src_name) do
true ->
raise(ArgumentError, "attribute #{inspect(name)} has duplicate name option key\n\n [name: #{inspect(src_name)}] already exist\n")
false ->
opts
end
end
defp name_exists?(mod, name) do
name_exists?(mod, :attributes, name) || name_exists?(mod, :embed_ones, name) || name_exists?(mod, :embed_manys, name)
end
defp name_exists?(mod, :attributes, name) do
mod
|> Module.get_attribute(:attributes)
|> Enum.any?(fn {_name, opts} -> elem(opts[:name], 0) == name end)
end
defp name_exists?(mod, embeds, name) do
mod
|> Module.get_attribute(embeds)
|> Enum.any?(fn {_name, _mod, opts} -> elem(opts[:name], 0) == name end)
end
@doc """
Inserts the specified `@id_generator` to the list of attributes. Calls `add_id/3`.
"""
defmacro __id__(id_generator, name) do
quote do
Collection.add_id(__MODULE__, unquote(id_generator), unquote(name))
end
end
@doc """
Inserts the specified `@id_generator` to the list of attributes.
"""
def add_id(_mod, _id_generator, nil) do
end
def add_id(_mod, {nil, _type, _fun}, _name) do
end
def add_id(mod, {id, type, fun}, _name) do
Module.put_attribute(mod, :types, {id, type})
Module.put_attribute(mod, :attributes, {id, default: fun, name: {:_id, "_id"}})
end
@doc """
Inserts boilercode for the @type attribute.
"""
defmacro __type__(types) do
quote bind_quoted: [types: types] do
@type t() :: %__MODULE__{unquote_splicing(types)}
end
end
@doc """
Returns true, if the Module has the `dump/1` function.
"""
def has_dump_function?(mod) do
Keyword.has_key?(mod.__info__(:functions), :dump)
end
@doc """
Returns true, if the Module has the `load/1` function.
"""
def has_load_function?(mod) do
Keyword.has_key?(mod.__info__(:functions), :load)
end
@doc """
Returns the default arguments for the struct. They are used to provide the
default values in the `new/0` call.
"""
def struct_args(args) when is_list(args) do
Enum.map(args, fn {arg, func} -> struct_args(arg, func) end)
end
def struct_args(arg, func) do
quote do
{unquote(arg), unquote(func).()}
end
end
@doc """
Defines the `before_dump/1` function.
"""
defmacro before_dump(fun) do
quote do
Module.put_attribute(__MODULE__, :before_dump_fun, unquote(fun))
end
end
@doc """
Defines the `after_load/1` function.
"""
defmacro after_load(fun) do
quote do
Module.put_attribute(__MODULE__, :after_load_fun, unquote(fun))
end
end
@doc """
Adds the struct to the `embeds_one` list. Calls `__embeds_one__`
"""
defmacro embeds_one(name, mod, opts \\ []) do
quote do
Collection.__embeds_one__(__MODULE__, unquote(name), unquote(mod), unquote(opts))
end
end
@doc """
Adds the struct to the `embeds_one` list.
"""
def __embeds_one__(mod, name, target, opts) do
Module.put_attribute(mod, :embed_ones, {name, target, add_name(mod, opts, name)})
end
@doc """
Adds the struct to the `embeds_many` list. Calls `__embeds_many__`
"""
defmacro embeds_many(name, mod, opts \\ []) do
quote do
type = unquote(Macro.escape({{:., [], [mod, :t]}, [], []}))
Collection.__embeds_many__(__MODULE__, unquote(name), unquote(mod), type, unquote(opts))
end
end
@doc """
Adds the struct to the `embeds_many` list.
"""
def __embeds_many__(mod, name, target, type, opts) do
Module.put_attribute(mod, :types, {name, type})
Module.put_attribute(mod, :embed_manys, {name, target, add_name(mod, opts, name)})
end
@doc """
Adds the attribute to the attributes list. It call `__attribute__/4` function.
"""
defmacro attribute(name, type, opts \\ []) do
quote do
Collection.__attribute__(__MODULE__, unquote(name), unquote(Macro.escape(type)), unquote(opts))
end
end
@doc """
Adds the attribute to the attributes list.
"""
def __attribute__(mod, name, type, opts) do
if opts[:derived] do
Module.put_attribute(mod, :derived, name)
end
Module.put_attribute(mod, :types, {name, type})
Module.put_attribute(mod, :attributes, {name, add_name(mod, opts, name)})
end
@doc """
Inserts src_name for the timestamp attribute
"""
def timestamp(opts, name) when is_atom(name) do
case Keyword.get(opts, name, {name, name}) do
{name, src_name} -> {name, src_name}
name -> {name, name}
end
end
@doc """
Defines the `timestamps/1` function.
"""
defmacro timestamps(opts \\ []) do
quote bind_quoted: [opts: opts] do
{inserted_at, inserted_at_name} = timestamp(opts, :inserted_at)
{updated_at, updated_at_name} = timestamp(opts, :updated_at)
type = Keyword.get(opts, :type, DateTime)
ops =
opts
|> Keyword.drop([:inserted_at, :updated_at, :type])
|> Keyword.put_new(:default, &DateTime.utc_now/0)
Module.put_attribute(__MODULE__, :timestamps, {:inserted_at, inserted_at})
Module.put_attribute(__MODULE__, :timestamps, {:updated_at, updated_at})
Collection.__attribute__(__MODULE__, inserted_at, Macro.escape(type), Keyword.put(ops, :name, inserted_at_name))
Collection.__attribute__(__MODULE__, updated_at, Macro.escape(type), Keyword.put(ops, :name, updated_at_name))
end
end
def timestamps(struct, nil, _default), do: struct
def timestamps(struct, updated_at, opts) do
Map.put(struct, updated_at, opts[:default].())
end
def dump(%{__struct__: _} = struct) do
:maps.map(&dump/2, Map.from_struct(struct)) |> filter_nils()
end
def dump(map), do: :maps.map(&dump/2, map)
def dump(_key, value), do: ensure_nested_map(value)
defp ensure_nested_map(%{__struct__: Date} = data), do: data
defp ensure_nested_map(%{__struct__: DateTime} = data), do: data
defp ensure_nested_map(%{__struct__: NaiveDateTime} = data), do: data
defp ensure_nested_map(%{__struct__: Time} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.ObjectId} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.Binary} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.Regex} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.JavaScript} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.Timestamp} = data), do: data
defp ensure_nested_map(%{__struct__: BSON.LongNumber} = data), do: data
defp ensure_nested_map(%{__struct__: _} = struct) do
:maps.map(&dump/2, Map.from_struct(struct)) |> filter_nils()
end
defp ensure_nested_map(list) when is_list(list) do
Enum.map(list, &ensure_nested_map/1)
end
defp ensure_nested_map(data) do
data
end
def filter_nils(map) when is_map(map) do
map
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Enum.into(%{})
end
def filter_nils(keyword) when is_list(keyword) do
Enum.reject(keyword, fn {_key, value} -> is_nil(value) end)
end
##
# this function constructs a new struct with the specified attributes like this:
#
# in case of use_atoms = false:
#
# Map.put(%{
# first_name: Map.get(src_doc, "first_name"),
# last_name: Map.get(src_doc, "last_name"),
# email: Map.get(src_doc, "email"),
# labels: Label.load(Map.get(src_doc, "labels"), false)
# }, :__struct__, __MODULE__)
#
# in case of use_atoms = true:
# Map.put(%{
# first_name: Map.get(src_doc, :first_name),
# last_name: Map.get(src_doc, :last_name),
# email: Map.get(src_doc, :email),
# labels: Label.load(Map.get(src_doc, :labels), true)
# }, :__struct__, __MODULE__)
#
# It seems, this is the fastest way to construct a new struct:
#
# new quoted load 2.43 M
# load manually created using Map.get 2.25 M - 1.08x slower +32.91 ns
# load manually created using [] 2.12 M - 1.15x slower +60.34 ns
# original load 0.82 M - 2.96x slower +805.70 ns
##
def compile_load_steps(attributes, mod, use_atoms) do
args =
attributes
|> Enum.reverse()
|> Enum.map(fn
{dest_key, src_key} ->
quote do
{unquote(dest_key), Map.get(var!(src_doc), unquote(src_key))}
end
{mod, dest_key, src_key} ->
quote do
{unquote(dest_key), unquote(mod).load(Map.get(var!(src_doc), unquote(src_key)), unquote(use_atoms))}
end
end)
quote do
Map.put(%{unquote_splicing(args)}, :__struct__, unquote(mod))
end
end
def compile_dump_steps(attributes) do
args =
attributes
|> Enum.reverse()
|> Enum.map(fn
{dest_key, src_key} ->
quote do
{unquote(src_key), var!(src_doc).unquote(dest_key)}
end
{mod, dest_key, src_key} ->
quote do
{unquote(src_key), unquote(mod).dump(var!(src_doc).unquote(dest_key))}
end
end)
quote do
unquote(args)
|> Enum.filter(fn {_key, value} -> value != nil end)
|> Map.new()
end
end
end