Current section

Files

Jump to
caravela lib caravela gen graphql.ex
Raw

lib/caravela/gen/graphql.ex

defmodule Caravela.Gen.GraphQL do
@moduledoc """
Generates Absinthe types, queries, and mutations from a
`Caravela.Schema.Domain`.
Produces three files, one per Absinthe schema area:
* `lib/<web>/schema/<context>_types.ex` — object types
* `lib/<web>/schema/<context>_queries.ex` — list/get queries
* `lib/<web>/schema/<context>_mutations.ex` — create/update/delete
All three modules delegate to the context module generated by
`Caravela.Gen.Context`, so authorization, hooks, and (when enabled)
multi-tenant scoping flow through the Absinthe resolvers for free.
Tenant-injected fields are hidden from both the public object type
and the input_object — tenant id comes from the Absinthe context map,
not the client.
Returns a list of `{path, source}` tuples. The caller writes files.
Files preserve content below the `# --- CUSTOM ---` marker on
regeneration.
"""
alias Caravela.Schema.{Domain, Entity, Field}
alias Caravela.{Gen, Naming, Tenant, Types}
@types_template Path.expand("../../../priv/templates/graphql_types.eex", __DIR__)
@queries_template Path.expand("../../../priv/templates/graphql_queries.eex", __DIR__)
@mutations_template Path.expand("../../../priv/templates/graphql_mutations.eex", __DIR__)
@doc "Render Absinthe types, queries, and mutations."
def render_all(%Domain{} = domain, opts \\ []) do
[
render_types(domain, opts),
render_queries(domain, opts),
render_mutations(domain, opts)
]
end
@doc "Render the Absinthe types file."
def render_types(%Domain{} = domain, opts \\ []) do
path = file_path(domain, "types")
existing = existing_path(path, opts)
source =
EEx.eval_file(@types_template, assigns: types_assigns(domain), trim: true)
|> Gen.Custom.merge_with_file(existing)
|> Caravela.Gen.Format.try_format()
{path, source}
end
@doc "Render the Absinthe queries file."
def render_queries(%Domain{} = domain, opts \\ []) do
path = file_path(domain, "queries")
existing = existing_path(path, opts)
source =
EEx.eval_file(@queries_template, assigns: queries_assigns(domain), trim: true)
|> Gen.Custom.merge_with_file(existing)
|> Caravela.Gen.Format.try_format()
{path, source}
end
@doc "Render the Absinthe mutations file."
def render_mutations(%Domain{} = domain, opts \\ []) do
path = file_path(domain, "mutations")
existing = existing_path(path, opts)
source =
EEx.eval_file(@mutations_template, assigns: mutations_assigns(domain), trim: true)
|> Gen.Custom.merge_with_file(existing)
|> Caravela.Gen.Format.try_format()
{path, source}
end
# --- Module + file naming ----------------------------------------------
defp schema_module(domain, kind) do
base = Module.concat(Naming.web_module(domain), "Schema")
base =
case Domain.version_segment(domain) do
nil -> base
seg -> Module.concat(base, seg)
end
Module.concat(base, "#{context_camel(domain)}#{camelize_kind(kind)}")
end
defp file_path(domain, kind) do
web_root = Naming.web_module(domain) |> Module.split() |> List.first() |> Macro.underscore()
ctx_short = Naming.context_short(domain)
segments =
case Domain.version(domain) do
nil -> ["lib", web_root, "schema"]
v -> ["lib", web_root, "schema", v]
end
Path.join(segments ++ ["#{ctx_short}_#{kind}.ex"])
end
defp existing_path(path, opts) do
root = Keyword.get(opts, :root, File.cwd!())
Path.join(root, path)
end
defp camelize_kind("types"), do: "Types"
defp camelize_kind("queries"), do: "Queries"
defp camelize_kind("mutations"), do: "Mutations"
defp context_camel(domain) do
domain
|> Naming.context_short()
|> Macro.camelize()
end
# --- Types assigns ------------------------------------------------------
defp types_assigns(%Domain{} = domain) do
entities =
Enum.map(domain.entities, fn entity ->
%{
gql_name: gql_object_name(entity),
description: "A #{Naming.singular_string(entity.name)}",
fields: public_fields(entity) |> Enum.map(&field_assign/1),
assocs: assoc_assigns(domain, entity)
}
end)
[
types_module: schema_module(domain, "types"),
context_module: Naming.context_module(domain),
domain_module: domain.module,
entities: entities,
custom_marker: Gen.Custom.marker_block()
]
end
# --- Queries assigns ----------------------------------------------------
defp queries_assigns(%Domain{} = domain) do
entities =
Enum.map(domain.entities, fn entity ->
singular = Naming.singular_string(entity.name)
plural = Naming.plural_string(entity.name)
%{
gql_name: gql_object_name(entity),
singular: singular,
plural: plural,
list_field: String.to_atom(plural),
get_field: String.to_atom(singular),
list_fn: String.to_atom("list_#{plural}"),
get_fn: String.to_atom("get_#{singular}")
}
end)
[
queries_module: schema_module(domain, "queries"),
queries_field: queries_field_name(domain),
context_module: Naming.context_module(domain),
context_short: context_short_module(domain),
domain_module: domain.module,
entities: entities,
custom_marker: Gen.Custom.marker_block()
]
end
# --- Mutations assigns --------------------------------------------------
defp mutations_assigns(%Domain{} = domain) do
entities =
Enum.map(domain.entities, fn entity ->
singular = Naming.singular_string(entity.name)
plural = Naming.plural_string(entity.name)
%{
gql_name: gql_object_name(entity),
input_name: String.to_atom("#{singular}_input"),
singular: singular,
plural: plural,
create_field: String.to_atom("create_#{singular}"),
update_field: String.to_atom("update_#{singular}"),
delete_field: String.to_atom("delete_#{singular}"),
create_fn: String.to_atom("create_#{singular}"),
update_fn: String.to_atom("update_#{singular}"),
delete_fn: String.to_atom("delete_#{singular}"),
get_fn: String.to_atom("get_#{singular}"),
input_fields: input_fields(entity) |> Enum.map(&field_assign/1)
}
end)
[
mutations_module: schema_module(domain, "mutations"),
mutations_field: mutations_field_name(domain),
context_module: Naming.context_module(domain),
context_short: context_short_module(domain),
domain_module: domain.module,
entities: entities,
custom_marker: Gen.Custom.marker_block()
]
end
# --- Field shaping ------------------------------------------------------
# Public object fields: every declared field minus the tenant marker.
defp public_fields(%Entity{fields: fields}) do
Enum.reject(fields, &Tenant.injected?/1)
end
# Input fields: same as public, but optional foreign keys are declared
# manually by the developer (Caravela does not infer them here).
defp input_fields(entity), do: public_fields(entity)
defp field_assign(%Field{name: name, type: type, opts: opts}) do
required? = Keyword.get(opts || [], :required, false)
gql_type = gql_scalar(type)
wrapped = if required?, do: "non_null(#{gql_type})", else: gql_type
%{gql_name: name, gql_type: wrapped}
end
defp assoc_assigns(%Domain{} = domain, %Entity{name: ename}) do
Enum.flat_map(domain.relations, fn rel ->
cond do
rel.from == ename ->
[assoc_assign_from_declared(domain, rel)]
rel.to == ename ->
[assoc_assign_from_inferred(domain, rel)]
true ->
[]
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq_by(& &1.gql_name)
end
defp assoc_assign_from_declared(_domain, rel) do
case rel.type do
:has_many ->
%{
gql_name: rel.to,
gql_type: "list_of(#{inspect(gql_entity_name(rel.to))})"
}
:has_one ->
%{
gql_name: Naming.belongs_to_name(rel.to),
gql_type: inspect(gql_entity_name(rel.to))
}
:belongs_to ->
%{
gql_name: Naming.belongs_to_name(rel.to),
gql_type: inspect(gql_entity_name(rel.to))
}
_ ->
nil
end
end
defp assoc_assign_from_inferred(_domain, rel) do
case rel.type do
:has_many ->
%{
gql_name: Naming.belongs_to_name(rel.from),
gql_type: inspect(gql_entity_name(rel.from))
}
:has_one ->
%{
gql_name: Naming.belongs_to_name(rel.from),
gql_type: inspect(gql_entity_name(rel.from))
}
:belongs_to ->
%{
gql_name: rel.from,
gql_type: "list_of(#{inspect(gql_entity_name(rel.from))})"
}
_ ->
nil
end
end
# --- Scalar mapping -----------------------------------------------------
defp gql_scalar(type) do
case type do
t when t in [:string, :text] -> ":string"
t when t in [:integer, :bigint] -> ":integer"
:float -> ":float"
:decimal -> ":decimal"
:boolean -> ":boolean"
:date -> ":date"
:time -> ":time"
t when t in [:naive_datetime, :utc_datetime] -> ":datetime"
:binary -> ":string"
t when t in [:binary_id, :uuid] -> ":id"
t when t in [:map, :json, :jsonb] -> ":json"
other -> ":" <> Atom.to_string(other_known_or_string(other))
end
end
defp other_known_or_string(atom) do
if Types.known?(atom), do: atom, else: :string
end
# `:books` → `:book` (singular for GraphQL object name)
defp gql_object_name(%Entity{name: name}), do: gql_entity_name(name)
defp gql_entity_name(entity_name) do
entity_name
|> Naming.singularize()
end
defp queries_field_name(%Domain{} = domain) do
base = domain |> Naming.context_short()
String.to_atom("#{base}_queries")
end
defp mutations_field_name(%Domain{} = domain) do
base = domain |> Naming.context_short()
String.to_atom("#{base}_mutations")
end
# Short alias name used inside the generated file (last segment of
# the context module).
defp context_short_module(%Domain{} = domain) do
domain
|> Naming.context_module()
|> Module.split()
|> List.last()
end
end