Skip to content

Commit

Permalink
doc: handle elixir type info in from_map/1 & to_map/1
Browse files Browse the repository at this point in the history
If a top-level key named "type": "Foo" is present, check to see if a
corresponding atom named :"Elixir.Foo" is present in the existing
atom table.

This permits user functions to dispatch based on atom type in the
returned doc struct, to an Ecto schema function directly, or similar
custom user function as required.

- upgrade from_map/1 and to_map/1
- add coerce_to_elixir_type/1
- add coerce_to_json_string/1
- remove struct related names from type fields
- sprinkle a few tests
  • Loading branch information
dch committed May 14, 2021
1 parent b0628c5 commit 39ef420
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 7 deletions.
53 changes: 49 additions & 4 deletions lib/sofa/doc.ex
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ defmodule Sofa.Doc do
}} ->
{:ok, %Doc{doc | rev: rev}}

{:ok, _sofa,
%Sofa.Response{
status: 202,
body: %{"rev" => rev}
}} ->
{:ok, %Doc{doc | rev: rev}}

{:error,
%Sofa.Response{
status: 400
Expand Down Expand Up @@ -197,22 +204,34 @@ defmodule Sofa.Doc do
|> Map.put("_id", id)
|> Map.put("_rev", rev)
|> Map.put("_attachments", atts)
|> Map.put("type", type)
|> Map.put("type", coerce_to_json_string(type))

# skip all top level keys with value nil
m = :maps.filter(&Sofa.Doc.drop_nil_values/2, m)
m = :maps.filter(&drop_nil_values/2, m)
# merge with precedence taking from Struct side
Map.merge(body, m)
end

@spec drop_nil_values(any, any) :: false | true
def drop_nil_values(_, v) do
defp drop_nil_values(_, v) do
case v do
nil -> false
_ -> true
end
end

@spec coerce_to_json_string(atom) :: String.t() | nil
defp coerce_to_json_string(nil), do: nil
# _users docs (type: :user, id: "org.couchdb.user:...) are special
defp coerce_to_json_string(:user), do: "user"

defp coerce_to_json_string(atom) do
case Atom.to_string(atom) do
"Elixir." <> module -> module
_ -> nil
end
end

@doc """
Converts CouchDB-native JSON-friendly map to internal %Sofa.Doc{} format
Expand All @@ -233,10 +252,12 @@ defmodule Sofa.Doc do
# key beginning with "_" as they are restricted within CouchDB
body =
Map.drop(m, [
"__struct__",
"_attachments",
"_id",
"_rev",
"type",
:__struct__,
:_attachments,
:_id,
:_rev
Expand All @@ -246,10 +267,34 @@ defmodule Sofa.Doc do
# grab the rest we need them
rev = Map.get(m, "_rev", nil)
atts = Map.get(m, "_attachments", nil)
type = Map.get(m, "type", "nil") |> String.to_existing_atom()
type = Map.get(m, "type", "nil") |> coerce_to_elixir_type()
%Sofa.Doc{attachments: atts, body: body, id: id, rev: rev, type: type}
end

@doc """
Coerces a CouchDB "type" field to an existing atom. It is assumed that there
will be a related Elixir Module Type of the same name. Elixir prefixes Module
names with Elixir. and then elides this in iex, tests, and elsewhere, but
here we need to make that explicit.
The "user" type is special-cased as it is already present in CouchDB /_users
database.
This function is expected to be paired up with Ecto Schemas to properly manage
the appropriate fields in your document body.
"""
@spec coerce_to_elixir_type(String.t()) :: atom
defp coerce_to_elixir_type("user"), do: :user

defp coerce_to_elixir_type(type) do
# exception generated if no existing type is found
String.to_existing_atom("Elixir." <> type)
rescue
ArgumentError -> nil
else
found -> found
end

# this would be a Protocol for people to defimpl on their own structs
# @spec from_struct(map()) :: %Sofa.Doc{}
# def from_struct(m = %{id: id, __Struct__: type}) do
Expand Down
26 changes: 23 additions & 3 deletions test/sofa_doc_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ defmodule SofaDocTest do
end

test "from_map doesn't leak forbidden keys into doc.body" do
bad_keys = ["_rev", "_attachments", :_id, :_rev, :_attachments]
good_map = %{"_id" => "toasty", "key" => "important", "type" => "hellvetica"}
bad_keys = ["_rev", "_attachments", :_id, :_rev, :_attachments, :__struct__, "__struct__"]
good_map = %{"_id" => "toasty", "key" => "important", "type" => "Sofa"}

pruned_map =
Enum.reduce(bad_keys, good_map, fn x, a -> Map.put(a, x, "blah") end)
Expand All @@ -271,7 +271,27 @@ defmodule SofaDocTest do
body: %{"key" => "important"},
id: "toasty",
rev: "blah",
type: :hellvetica
type: Sofa
})
end

test "to_map coerces Elixir module types to JSON strings" do
doc = Sofa.Doc.new("Hellvetica")
m = %{doc | type: Sofa.Doc} |> Sofa.Doc.to_map()
assert "Sofa.Doc" == m["type"]

assert !Map.has_key?(Sofa.Doc.to_map(doc), "type")
end

test "from_map coerces type strings to Elixir types" do
m = %{"_rev" => "1-leet", "_id" => "abc123", "type" => "Sofa.Doc"}

assert Map.equal?(Sofa.Doc.from_map(m), %Sofa.Doc{
attachments: nil,
body: %{},
id: "abc123",
rev: "1-leet",
type: Sofa.Doc
})
end

Expand Down
14 changes: 14 additions & 0 deletions test/sofa_user_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ defmodule SofaUserTest do
})
end

test ":user type is correctly converted to `user` JSON string" do
doc = Sofa.User.new("jabberwocky")
map = Sofa.Doc.to_map(doc)

assert map["type"] == "user"
end

test ":user type round-trips through JSON correctly" do
doc = Sofa.User.new("jabberwocky")
map = Sofa.Doc.to_map(doc)

assert Map.equal?(doc, Sofa.Doc.from_map(map))
end

test "password field is removed & converted to salted hashed form on GET after PUT" do
response =
Sofa.connect!(@plain_sofa)
Expand Down

0 comments on commit 39ef420

Please sign in to comment.