Last active
June 16, 2020 10:56
-
-
Save florianb/2ff78edc695b309cbf31a698ddb3b4ca to your computer and use it in GitHub Desktop.
Draft for Elixir-based CouchDb design documents in Erlang.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Making Elixir-modules accessible seems not being possible in a quick way. So for now you'd have to write Elixir dyed Erlang.