defmodule Skuld.Query.Contract do @moduledoc """ Macro for defining typed batchable fetch contracts with `deffetch` declarations. A Query contract defines a set of fetch operations, generating: * **Operation structs** — nested struct modules per fetch (e.g., `MyContract.GetUser`) * **Caller functions** — typed public API returning `computation(return_type)`, which suspend the current fiber for batched execution * **Executor behaviour** — typed callbacks for batch execution, one per fetch * **Dispatch function** — `__dispatch__/3` routes from batch key to executor callback * **Introspection** — `__query_operations__/0` """ alias Skuld.Comp # ------------------------------------------------------------------- # Bulk Wiring (non-generated, lives on Contract module itself) # ------------------------------------------------------------------- @doc """ Install multiple contract/executor pairs in one call. Accepts either a list of `{contract_module, executor_module}` tuples or a map. ## Examples comp |> Skuld.Query.Contract.with_executors([ {MyApp.Queries.Users, MyApp.Queries.Users.EctoExecutor}, {MyApp.Queries.Orders, MyApp.Queries.Orders.EctoExecutor} ]) comp |> Skuld.Query.Contract.with_executors(%{ MyApp.Queries.Users => MyApp.Queries.Users.EctoExecutor, MyApp.Queries.Orders => MyApp.Queries.Orders.EctoExecutor }) """ @spec with_executors(Comp.Types.computation(), [{module(), module()}] | %{module() => module()}) :: Comp.Types.computation() def with_executors(comp, pairs) when is_map(pairs) do with_executors(comp, Map.to_list(pairs)) end def with_executors(comp, pairs) when is_list(pairs) do Enum.reduce(pairs, comp, fn {contract, executor}, acc -> operations = contract.__query_operations__() entries = Enum.map(operations, fn %{name: query_name} -> {{contract, query_name}, fn ops -> contract.__dispatch__(executor, query_name, ops) end} end) Skuld.FiberPool.BatchExecutor.with_executors(acc, entries) end) end # ------------------------------------------------------------------- # __using__ / deffetch / __before_compile__ # ------------------------------------------------------------------- @doc false defmacro __using__(_opts) do quote do import Skuld.Query.Contract, only: [deffetch: 1, deffetch: 2] Module.register_attribute(__MODULE__, :query_operations, accumulate: true) @before_compile Skuld.Query.Contract end end @doc """ Define a typed fetch operation. ## Syntax deffetch function_name(param :: type(), ...) :: return_type() deffetch function_name(param :: type(), ...) :: return_type(), cache: bool() Each `deffetch` declaration generates an operation struct, caller function, executor callback, and dispatch clause. """ defmacro deffetch(spec, opts \\ []) defmacro deffetch({:"::", _meta, [call_ast, return_type_ast]}, opts) do cache_opt = Keyword.get(opts, :cache, true) build_deffetch_ast(call_ast, return_type_ast, cache_opt, __CALLER__) end defmacro deffetch(other, _opts) do raise CompileError, description: "invalid deffetch syntax. Expected: deffetch name(param :: type(), ...) :: return_type()\n" <> "Got: #{Macro.to_string(other)}", file: __CALLER__.file, line: __CALLER__.line end defp build_deffetch_ast(call_ast, return_type_ast, cache_opt, caller) do {name, params} = DoubleDown.Contract.parse_call(call_ast, caller) param_names = Enum.map(params, &elem(&1, 0)) param_types = Enum.map(params, &elem(&1, 1)) op_base = %{ name: name, param_names: param_names, param_types: param_types, return_type: return_type_ast, cacheable: cache_opt, user_doc: nil } escaped_op = Macro.escape(op_base) quote do @query_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__ # ------------------------------------------------------------------- @doc false defmacro __before_compile__(env) do operations = Module.get_attribute(env.module, :query_operations) |> Enum.reverse() if operations == [] do raise CompileError, description: "#{inspect(env.module)} uses Query.Contract but has no deffetch declarations", file: env.file, line: 0 end # Generate all artefacts struct_modules = Enum.map(operations, &generate_struct_module(env.module, &1)) callers = Enum.map(operations, &generate_caller(env.module, &1)) callbacks = generate_contract_callbacks(env.module, operations) dispatch_fns = Enum.map(operations, &generate_dispatch/1) introspection = generate_introspection(operations) quote do # Operation struct modules (must be defined before use in caller bodies) (unquote_splicing(struct_modules)) # Caller functions unquote_splicing(callers) # Callback declarations unquote_splicing(callbacks) # Dispatch functions unquote_splicing(dispatch_fns) # Introspection unquote(introspection) end end # ------------------------------------------------------------------- # Struct Generation (skuld-8fg) # ------------------------------------------------------------------- defp generate_struct_module(contract_module, %{ name: name, param_names: param_names, param_types: param_types }) do struct_module_name = to_pascal_case(name) struct_module = Module.concat(contract_module, struct_module_name) struct_fields = Enum.map(param_names, fn pname -> {pname, nil} end) # Build @type t :: %__MODULE__{field1: type1, field2: type2} type_fields = Enum.zip(param_names, param_types) |> Enum.map(fn {pname, ptype} -> {pname, ptype} end) type_ast = if type_fields == [] do quote do @type t :: %__MODULE__{} end else field_types = Enum.map(type_fields, fn {pname, ptype} -> {pname, ptype} end) quote do @type t :: %__MODULE__{unquote_splicing(field_types)} end end quote do defmodule unquote(struct_module) do @moduledoc false defstruct unquote(struct_fields) unquote(type_ast) end end end # ------------------------------------------------------------------- # Caller Generation (skuld-1x9) # ------------------------------------------------------------------- defp generate_caller(contract_module, %{ name: name, param_names: param_names, param_types: param_types, return_type: return_type, user_doc: user_doc }) do param_vars = Enum.map(param_names, fn pname -> {pname, [], nil} end) spec_params = param_types comp_type = {:computation, [], [return_type]} struct_module_name = to_pascal_case(name) struct_module = Module.concat(contract_module, struct_module_name) # Build struct creation: %StructModule{field1: var1, field2: var2} struct_fields = Enum.zip(param_names, param_vars) |> Enum.map(fn {pname, pvar} -> {pname, pvar} end) doc_ast = if user_doc do {_line, doc_content} = user_doc quote do @doc unquote(doc_content) end else doc_string = "Fetch operation: `#{name}/#{length(param_names)}`\n\nSuspends the current fiber for batched execution.\n" quote do @doc unquote(doc_string) end end quote do unquote(doc_ast) @spec unquote(name)(unquote_splicing(spec_params)) :: Skuld.Comp.Types.unquote(comp_type) def unquote(name)(unquote_splicing(param_vars)) do Skuld.QueryContract.batchable_op( {unquote(contract_module), unquote(name)}, %unquote(struct_module){unquote_splicing(struct_fields)} ) end end end # ------------------------------------------------------------------- # Callback Generation (skuld-azy) # ------------------------------------------------------------------- defp generate_contract_callbacks(contract_module, operations) do Enum.map(operations, fn %{name: name, return_type: return_type} -> struct_module_name = to_pascal_case(name) struct_module = Module.concat(contract_module, struct_module_name) result_map_type = {:%{}, [], [{{:reference, [], []}, return_type}]} comp_type = {:computation, [], [result_map_type]} quote do @callback unquote(name)(ops :: [{reference(), unquote(struct_module).t()}]) :: Skuld.Comp.Types.unquote(comp_type) end end) end # ------------------------------------------------------------------- # Dispatch Generation (skuld-avo) # ------------------------------------------------------------------- defp generate_dispatch(%{name: name}) do quote do @doc false def __dispatch__(executor_impl, unquote(name), ops) do executor_impl.unquote(name)(ops) end end end # ------------------------------------------------------------------- # Introspection (skuld-avo) # ------------------------------------------------------------------- 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, cacheable: cacheable } -> quote do %{ name: unquote(name), params: unquote(param_names), param_types: unquote(Macro.escape(param_types)), return_type: unquote(Macro.escape(return_type)), arity: unquote(length(param_names)), cacheable: unquote(cacheable) } end end) quote do @doc false def __query_operations__ do unquote(op_maps) end end end # ------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------- # Convert snake_case atom to PascalCase atom. # e.g., :get_user -> :GetUser, :get_users_by_org -> :GetUsersByOrg @doc false def to_pascal_case(atom) when is_atom(atom) do atom |> Atom.to_string() |> String.split("_") |> Enum.map_join(&String.capitalize/1) |> String.to_atom() end end