Compile time checking struct keys for a protocol

I have a protocol that is used on Ecto Schemas. The protocol requires that two fields are defined with specific defaults.

I have come up with a working solution that I’m pretty happy with, but I’m just looking for feedback as I’m unsure if this is the optimal way (and maybe I’ve looked past a much simpler solution). In short, I used __after_compile__ and Ecto’s reflection functions to ensure the keys are there.

Obviously one drawback of this is that it must be @derived though I’m not so worried about that.

I’m also wondering about the line %{context_modules: [module]} <- env and if that could have any ramifications. Could there ever be more than one context module in this scenario?

Here is the code:

defmodule MyApp.MyProtocolError do
  defexception [:message]
end

defmodule MyApp.MyProtocolCompile do
  @required_fields [
    name: %{type: :string},
    checked: %{type: :boolean, default: true}
  ]

  def __after_compile__(env, _bytecode) do
    with %{context_modules: [module]} <- env do
      messages =
        for {field, opts} <- required_fields, reduce: [] do
          messages ->
            if module.__schema__(:virtual_type, field) != opts.type do
              message =
                " * #{__MODULE__} must have a field `:#{field}` of type `:#{opts.type}`"

              message =
                message <>
                  ((Map.has_key?(opts, :default) && " and default to `#{opts.default}`") || "")

              [message | messages]
            else
              messages
            end
        end

      if messages != [] do
        raise MyApp.MyProtocolError,
          message: "\n" <> Enum.join(messages, "\n")
      end
    end
  end
end

defprotocol MyApp.MyProtocol do
  def do_it(module)

  @impl true
  defmacro __deriving__(module, options) do
    quote location: :keep do
      @after_compile MyApp.MyProtocolCompile

      defimpl MyApp.MyProtocol, for: unquote(module) do
        def do_it(_module) do
          unquote(options).key
        end
      end
    end
  end
end

The other thing I did was put the __after_compile__ in the __deriving__ so I could just unquote(module) to get the module, but I like this way a bit better.

3 Likes