Packages
double_down
0.40.0
0.69.0
0.68.0
0.66.0
0.65.0
0.64.1
0.64.0
0.63.3
0.63.2
0.63.1
0.63.0
0.62.1
0.61.0
0.60.4
0.60.3
0.60.2
0.60.1
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.1
0.48.0
0.47.2
0.47.1
0.47.0
0.46.3
0.46.2
0.46.1
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.2
0.37.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.1
0.30.0
0.29.0
0.28.1
0.28.0
0.27.0
0.26.0
0.24.0
Builds on the Mox pattern — generates behaviours and dispatch facades from `defcallback` declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.
Current section
Files
Jump to
Current section
Files
lib/double_down/contract.ex
defmodule DoubleDown.Contract do
@moduledoc """
Macro for defining contract behaviours with `defcallback` declarations.
`use DoubleDown.Contract` imports the `defcallback` macro and registers a
`@before_compile` hook that generates:
* `@callback` declarations on the contract module itself — the
contract module *is* the behaviour
* `__callbacks__/0` — introspection metadata used by `DoubleDown.Facade`
to generate dispatch functions
Contracts are purely static interface definitions. They do **not**
generate a dispatch facade — that is the concern of
`DoubleDown.Facade`, which the consuming application uses separately to
bind a contract to an OTP application's config.
## Usage
defmodule MyApp.Todos do
use DoubleDown.Contract
defcallback get_todo(tenant_id :: String.t(), id :: String.t()) ::
{:ok, Todo.t()} | {:error, term()}
defcallback list_todos(tenant_id :: String.t()) :: [Todo.t()]
end
This generates `@callback` declarations on `MyApp.Todos` and
`MyApp.Todos.__callbacks__/0`.
Implementations use `@behaviour MyApp.Todos` directly:
defmodule MyApp.Todos.Ecto do
@behaviour MyApp.Todos
# ...
end
Compatible with `Mox.defmock(Mock, for: MyApp.Todos)`.
To generate a dispatch facade, use `DoubleDown.Facade` in a separate module:
defmodule MyApp.Todos do
use DoubleDown.Facade, contract: MyApp.Todos.Contract, otp_app: :my_app
end
See `DoubleDown.Facade` for dispatch configuration and `DoubleDown` for an overview.
"""
@doc false
defmacro __using__(_opts) do
quote do
# import is always safe to repeat and must be at module scope
# (imports inside blocks like `unless` are scoped to that block)
import DoubleDown.Contract, only: [defcallback: 1, defcallback: 2]
# Guard the non-idempotent parts: registering the accumulator attribute
# and the @before_compile hook. This makes `use DoubleDown.Contract`
# idempotent, so a module can both `use DoubleDown.Contract` directly and
# have it called by a wrapper macro internally.
unless Module.has_attribute?(__MODULE__, :callback_operations) do
Module.register_attribute(__MODULE__, :callback_operations, accumulate: true)
@before_compile DoubleDown.Contract
end
end
end
@doc """
Define a typed callback operation.
`defcallback` uses a superset of the standard `@callback` syntax,
with mandatory parameter names and optional metadata. If your
existing `@callback` declarations include parameter names, you can
replace `@callback` with `defcallback` and you're done:
# Standard @callback — already valid as a defcallback
@callback get_todo(id :: String.t()) :: {:ok, Todo.t()} | {:error, term()}
# Equivalent defcallback
defcallback get_todo(id :: String.t()) :: {:ok, Todo.t()} | {:error, term()}
## Why `defcallback` instead of plain `@callback`?
* **Parameter names are mandatory.** Plain `@callback` allows
unnamed parameters like `@callback get(term(), term()) :: term()`.
`defcallback` requires `name :: type()` for every parameter — these
are used to generate meaningful `@spec` and `@doc` on the facade.
* **Combined contract + facade.** `Code.Typespec.fetch_callbacks/1`
only works on pre-compiled modules with beam files on disk, ruling
out the combined contract + facade pattern entirely. `defcallback`
captures metadata at macro expansion time via `__callbacks__/0`,
so the contract and facade can live in the same module.
* **LSP-friendly docs.** Plain `@callback` declarations don't
support `@doc` — the best you can do is `#` comments that won't
appear in hover docs. With `defcallback`, `@doc` placed above the
declaration resolves on both the declaration and on any call site
that goes through the facade.
* **Additional metadata.** `defcallback` supports options like
`pre_dispatch:` (argument transforms before dispatch). Plain
`@callback` has no mechanism for this.
See [Why `defcallback` instead of plain `@callback`?](docs/getting-started.md#why-defcallback-instead-of-plain-callback)
in the Getting Started guide for the full rationale.
## Syntax
defcallback function_name(param :: type(), ...) :: return_type()
defcallback function_name(param :: type(), ...) :: return_type(), opts
## Options
### Pre-dispatch transform (`:pre_dispatch`)
* **`:pre_dispatch`** — a function `(args, facade_module) -> args` that
transforms the argument list before dispatch. The function receives the
args as a list and the facade module atom, and must return the
(possibly modified) args list. This is useful for injecting
facade-specific context into arguments at the dispatch boundary.
Most contracts don't need this — the canonical example is
`DoubleDown.Repo`'s `transact` operation.
### Typespec mismatch severity (`:warn_on_typespec_mismatch?`)
* **omitted / `false`** (default) — raise `CompileError` when the
`defcallback` type spec doesn't match the production impl's `@spec`.
* **`true`** — emit a warning instead of an error. Use this during
migration when you know the specs differ and want to defer fixing them.
See `DoubleDown.Contract.SpecWarnings` for details on compile-time
spec mismatch detection.
"""
defmacro defcallback(spec, opts \\ [])
defmacro defcallback({:"::", _meta, [call_ast, return_type_ast]}, opts) do
pre_dispatch_opt = Keyword.get(opts, :pre_dispatch, nil)
warn_on_typespec_mismatch? = Keyword.get(opts, :warn_on_typespec_mismatch?, false)
build_defcallback_ast(
call_ast,
return_type_ast,
pre_dispatch_opt,
warn_on_typespec_mismatch?,
__CALLER__
)
end
defmacro defcallback(other, _opts) do
raise CompileError,
description:
"invalid defcallback syntax. Expected: defcallback name(param :: type(), ...) :: return_type()\n" <>
"Got: #{Macro.to_string(other)}",
file: __CALLER__.file,
line: __CALLER__.line
end
# -- AST capture (at macro expansion time) --
defp build_defcallback_ast(
call_ast,
return_type_ast,
pre_dispatch_opt,
warn_on_typespec_mismatch?,
caller
) do
{name, params} = parse_call(call_ast, caller)
param_names = Enum.map(params, &elem(&1, 0))
param_types =
Enum.map(params, fn {_name, type_ast} -> expand_type_aliases(type_ast, caller) end)
return_type_ast = expand_type_aliases(return_type_ast, caller)
op_base = %{
name: name,
param_names: param_names,
param_types: param_types,
return_type: return_type_ast,
pre_dispatch: pre_dispatch_opt,
warn_on_typespec_mismatch?: warn_on_typespec_mismatch?,
user_doc: nil
}
escaped_op = Macro.escape(op_base)
quote do
@callback_operations (fn ->
user_doc = Module.get_attribute(__MODULE__, :doc)
op = %{unquote(escaped_op) | user_doc: user_doc}
if user_doc, do: Module.delete_attribute(__MODULE__, :doc)
op
end).()
end
end
# -- Before compile: generate @callbacks + introspection --
@doc false
defmacro __before_compile__(env) do
operations = Module.get_attribute(env.module, :callback_operations) |> Enum.reverse()
if operations == [] do
raise CompileError,
description:
"#{inspect(env.module)} uses DoubleDown.Contract but has no defcallback declarations",
file: env.file,
line: 0
end
callbacks = Enum.map(operations, &generate_callback/1)
introspection = generate_introspection(operations)
quote do
unquote_splicing(callbacks)
unquote(introspection)
end
end
# -- AST Parsing --
@doc false
def parse_call({name, _meta, nil}, _caller) when is_atom(name) do
{name, []}
end
def parse_call({name, _meta, args}, caller) when is_atom(name) and is_list(args) do
params =
Enum.map(args, fn
{:"::", _, [{param_name, _, _}, type_ast]} when is_atom(param_name) ->
{param_name, type_ast}
{:\\, _, _} ->
raise CompileError,
description:
"defcallback does not support default arguments (\\\\). " <>
"Use a wrapper function instead.",
file: caller.file,
line: caller.line
other ->
raise CompileError,
description:
"defcallback parameters must be typed: `name :: type()`. Got: #{Macro.to_string(other)}",
file: caller.file,
line: caller.line
end)
{name, params}
end
def parse_call(other, caller) do
raise CompileError,
description: "invalid defcallback call syntax. Got: #{Macro.to_string(other)}",
file: caller.file,
line: caller.line
end
# -- Type alias expansion --
#
# Walk a type AST and expand any {:__aliases__, _, _} nodes using the
# caller's alias environment. This ensures that __callbacks__/0
# always stores fully-qualified module names, so @spec annotations
# generated in Port modules (which lack the contract's aliases) resolve
# correctly for dialyzer.
@doc false
def expand_type_aliases(ast, caller_env) do
Macro.prewalk(ast, fn
{:__aliases__, _meta, _segments} = node ->
Macro.expand(node, caller_env)
other ->
other
end)
end
# -- Code Generation: Behaviour callbacks --
defp generate_callback(%{
name: name,
param_names: param_names,
param_types: param_types,
return_type: return_type
}) do
callback_params =
Enum.zip(param_names, param_types)
|> Enum.map(fn {pname, ptype} ->
{:"::", [], [{pname, [], nil}, ptype]}
end)
quote do
@callback unquote(name)(unquote_splicing(callback_params)) :: unquote(return_type)
end
end
# -- Code Generation: Introspection --
defp generate_introspection(operations) do
op_maps =
Enum.map(operations, fn %{
name: name,
param_names: param_names,
param_types: param_types,
return_type: return_type,
pre_dispatch: pre_dispatch,
warn_on_typespec_mismatch?: warn_on_typespec_mismatch?,
user_doc: user_doc
} ->
quote do
%{
name: unquote(name),
params: unquote(param_names),
param_types: unquote(Macro.escape(param_types)),
return_type: unquote(Macro.escape(return_type)),
pre_dispatch: unquote(Macro.escape(pre_dispatch)),
warn_on_typespec_mismatch?: unquote(warn_on_typespec_mismatch?),
user_doc: unquote(Macro.escape(user_doc)),
arity: unquote(length(param_names))
}
end
end)
quote do
@doc false
def __callbacks__ do
unquote(op_maps)
end
end
end
end