Skip to content

Instantly share code, notes, and snippets.

@florianb
Last active June 16, 2020 10:56
Show Gist options
  • Select an option

  • Save florianb/2ff78edc695b309cbf31a698ddb3b4ca to your computer and use it in GitHub Desktop.

Select an option

Save florianb/2ff78edc695b309cbf31a698ddb3b4ca to your computer and use it in GitHub Desktop.
Draft for Elixir-based CouchDb design documents in Erlang.
defmodule DatabaseServices.CouchDb.DesignDocument do
@moduledoc false
alias DatabaseServices.CouchDb.Document
def put(module, opts \\ []) do
couch_keys = ["_id", "_rev", :_id, :_rev]
view_name = module |> extract_ddoc_name()
id = "/_design/#{view_name}"
view =
module.to_map()
|> Map.merge(%{_id: id})
case Document.get(id, opts) do
{:error, "missing"} ->
Document.put(view, opts)
{:ok, fetched_view} ->
bare_view = view |> Map.drop(couch_keys)
%{changed: state} =
fetched_view
|> Map.drop(couch_keys)
|> MapDiff.diff(bare_view)
if state != :equal do
view
|> Map.merge(%{"_rev" => fetched_view["_rev"]})
|> Document.put(opts)
else
{:skipped, "already up to date"}
end
r ->
r
end
end
defp extract_ddoc_name(id) do
id
|> Atom.to_string()
|> String.split(".")
|> Enum.take(-1)
|> List.first()
|> Macro.underscore()
|> String.replace_trailing("_design_document", "")
|> String.replace("_", "-")
end
defmacro __using__(opts) do
before_compile =
quote do
@before_compile unquote(__MODULE__)
end
ddoc_functions =
quote do
def emit(key, value), do: nil
end
if opts |> Keyword.get(:skip, false) == false do
[before_compile, ddoc_functions]
else
ddoc_functions
end
end
@dd_function_mappings %{
emit: :Emit,
start: :Start,
send: :Send,
get_row: :GetRow,
fold_rows: :FoldRows,
log: :Log
}
defmacro __before_compile__(env) do
ddoc =
env
|> get_module_bytecode()
|> generate_ddoc_from_bytecode()
|> Macro.escape()
quote do
def to_map do
unquote(ddoc)
end
end
end
defp get_module_bytecode(env) do
{:ok, module_file} =
env.file
|> File.read()
{:ok, file_ast} =
module_file
|> Code.string_to_quoted()
this_module_name = __MODULE__ |> Atom.to_string()
this_module_chain =
__MODULE__
|> Atom.to_string()
|> String.split(".")
|> Enum.map(fn e -> String.to_atom(e) end)
module_chain =
env.module
|> Atom.to_string()
|> String.split(".")
|> Enum.map(fn e -> String.to_atom(e) end)
{_, {_chain, module_ast}} =
file_ast
# Search the file ast for the given module
|> Macro.traverse(
# Prepend the Elixir-namespace for comparison
{[:"Elixir"], nil},
fn
# Match module definitions
{:defmodule, _lno, exprs} = module_expression, {chain, match} ->
name_chain = find_aliases_args(exprs)
chain = chain ++ [name_chain]
flattened_chain = chain |> List.flatten()
# if the chain matches, return the expressions-list
match = if flattened_chain == module_chain, do: exprs, else: match
{module_expression, {chain, match}}
curr, acc ->
{curr, acc}
end,
fn
{:defmodule, _lno, _exprs} = module_ast, {chain, match} ->
chain = chain |> Enum.drop(-1)
{module_ast, {chain, match}}
curr, acc ->
{curr, acc}
end
)
# Fix module-reference and add skip-flag to avoid circular "use"
|> Macro.prewalk(fn
{:use, lno_1, [{:__aliases__, lno_2, id_chain}]} = use_expression
when is_list(id_chain) ->
id =
id_chain
|> Enum.map(&Atom.to_string(&1))
|> Enum.join(".")
if this_module_name |> String.ends_with?(id) do
{:use, lno_1,
[{:__aliases__, lno_2, this_module_chain}, [skip: true]]}
else
use_expression
end
r ->
r
end)
:ok = module_ast |> Macro.validate()
{:module, _} = Code.ensure_loaded(__MODULE__)
{:module, interim_module, interim_bytecode, _interim_term} =
Module.create(SandBox, module_ast, Macro.Env.location(__ENV__))
:code.purge(interim_module)
true = :code.delete(interim_module)
interim_bytecode
end
# Searches the first occurence of the top-level __aliases__
defp find_aliases_args([{:__aliases__, _, args} | _tail]), do: args
defp find_aliases_args([_head | tail]), do: find_aliases_args(tail)
defp generate_ddoc_from_bytecode(bytecode) do
erl_ast = :beam_lib.chunks(bytecode, [:abstract_code])
view_map =
erl_ast
# get all functions
|> prewalk_for_function_definitions()
# map dd-functions or nil entries
|> Enum.map(fn f -> map_ast(f) end)
# reject nil entries
|> Enum.reject(fn e -> e == nil end)
|> assemble_design_document()
view_map
|> Map.put("language", "erlang")
end
# map dd-functions into indexed map
defp map_ast({:function, _, id, arity, _} = function) do
id =
id
|> Atom.to_string()
# check for permitted function prefix
if String.starts_with?(id, ~w(map_ reduce_ update_ filter_ index_)) or
id == "validate_doc_update" do
[prefix, name] =
id
|> String.split("_", parts: 2)
prefix = prefix |> String.to_existing_atom()
name = name |> kebab_case()
fun_expression =
function
|> function_to_fun_expression()
|> :erl_prettypr.format()
|> to_string()
|> String.replace(~r/end\w*/, "end.")
%{
prefix: prefix,
name: name,
arity: arity,
erl: fun_expression
}
else
nil
end
end
defp function_to_fun_expression(function_expression) do
function_expression
|> :erl_syntax.function_clauses()
|> :erl_syntax.fun_expr()
|> :erl_syntax.revert()
end
# prewalk erlang ast for function definitions
defp prewalk_for_function_definitions(ast, acc \\ [])
defp prewalk_for_function_definitions([], acc), do: acc
defp prewalk_for_function_definitions([element | expressions], acc) do
funs =
element
|> prewalk_for_function_definitions(acc)
prewalk_for_function_definitions(expressions, funs)
end
defp prewalk_for_function_definitions(
{:function, _lno, id, _arity, _args} = fun,
acc
) do
name = Atom.to_string(id)
if String.starts_with?(name, "_") do
acc
else
fun = prewalk_and_transform_dd_function_calls(fun)
[fun | acc]
end
end
defp prewalk_for_function_definitions(ast, acc) when is_tuple(ast) do
Tuple.to_list(ast)
|> prewalk_for_function_definitions(acc)
end
defp prewalk_for_function_definitions(_, acc), do: acc
defp prewalk_and_transform_dd_function_calls(ast)
defp prewalk_and_transform_dd_function_calls(
{:call, lno_1, {:atom, lno_2, id}, args} = whole_call
) do
case Map.get(@dd_function_mappings, id) do
nil ->
whole_call
new_id ->
{:call, lno_1, {:var, lno_2, new_id}, args}
end
end
defp prewalk_and_transform_dd_function_calls(ast)
when is_tuple(ast) do
ast
|> Tuple.to_list()
|> prewalk_and_transform_dd_function_calls()
|> List.to_tuple()
end
defp prewalk_and_transform_dd_function_calls(ast)
when is_list(ast) do
ast
|> Enum.map(fn e -> prewalk_and_transform_dd_function_calls(e) end)
end
defp prewalk_and_transform_dd_function_calls(any), do: any
defp assemble_design_document(function_maps, dd \\ %{})
defp assemble_design_document([], dd), do: dd
defp assemble_design_document(
[%{prefix: :map, name: name, erl: source, arity: _} | tail],
dd
) do
new_dd = assemble_design_document_view({"map", name, source, dd})
assemble_design_document(tail, new_dd)
end
defp assemble_design_document(
[%{prefix: :reduce, name: name, erl: source, arity: _} | tail],
dd
) do
new_dd = assemble_design_document_view({"reduce", name, source, dd})
assemble_design_document(tail, new_dd)
end
defp assemble_design_document(
[%{prefix: :update, name: name, erl: source, arity: _} | tail],
dd
) do
dd = if dd["updates"] == nil, do: Map.put(dd, "updates", %{}), else: dd
new_dd =
dd
|> put_in(["updates", name], source)
assemble_design_document(tail, new_dd)
end
defp assemble_design_document(
[%{prefix: :filter, name: name, erl: source, arity: _} | tail],
dd
) do
dd = if dd["filters"] == nil, do: Map.put(dd, "filters", %{}), else: dd
new_dd =
dd
|> put_in(["filters", name], source)
assemble_design_document(tail, new_dd)
end
defp assemble_design_document(
[%{prefix: :index, name: name, erl: source, arity: _} | tail],
dd
) do
dd = if dd["indexes"] == nil, do: Map.put(dd, "indexes", %{}), else: dd
new_dd =
dd
|> put_in(["indexes", name], source)
assemble_design_document(tail, new_dd)
end
defp assemble_design_document(
[
%{prefix: :validate, name: "doc_update", erl: source, arity: _}
| tail
],
dd
) do
new_dd =
dd
|> Map.put("validate_doc_update", source)
assemble_design_document(tail, new_dd)
end
defp assemble_design_document_view({prefix, name, source, dd}) do
dd = if dd["views"] == nil, do: Map.put(dd, "views", %{}), else: dd
if get_in(dd, ["views", name]) == nil do
dd |> put_in(["views", name], %{})
else
dd
end
|> put_in(["views", name, prefix], source)
end
defp kebab_case(str) do
str
|> Macro.camelize()
|> Macro.underscore()
|> String.replace("_", "-")
end
end
defmodule UserDesignDocument do
use CouchDb.DesignDocument
def map_users_by_name({doc}) do
name = :proplists.get_value("name", doc)
id = :proplists.get_value("_id", doc)
emit(name, id)
end
end
fun ({_doc@1}) ->
_name@1 = proplists:get_value(<<"name">>, _doc@1),
_id@1 = proplists:get_value(<<"_id">>, _doc@1),
Emit(_name@1, _id@1)
end.
@florianb
Copy link
Copy Markdown
Author

florianb commented Jun 10, 2020

Now that's work in progress, current goals:

  • Transpile Elixir functions into Erlang source code.
  • Provide a macro/function returning the design document as json.
  • Let the ddoc-functions (emit, ...) be testable.
  • Test the generated view against CouchDb.
  • Fix error where the module ast is extracted only by the last atom.
  • Add indexes
  • Clean the mess up.
  • Encapsulate it into a package - if there's anybody interested in it.
  • Test/Research ways of allowing the use of Elixir-std functions.
  • Provide some magic to catch calls to the internal CouchDb-functions (Emit, ...) in the tests.

@florianb
Copy link
Copy Markdown
Author

Making Elixir-modules accessible seems not being possible in a quick way. So for now you'd have to write Elixir dyed Erlang.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment