Current section

Files

Jump to
ex_shopify_schema lib mix ex_shopify_schema.ex
Raw

lib/mix/ex_shopify_schema.ex

defmodule Mix.ExShopifySchema do
@moduledoc """
Conveniences for writing ExShopifySchema related Mix tasks.
"""
import Mix.Generator
alias ExShopifySchema.Generator.Graphql.Introspection.Definition
@typep opts() :: [
dir: Path.t(),
namespace: module(),
ecto_types: %{(type_name :: String.t()) => {:field | :embeds, module(), Keyword.t()}}
]
@spec generate_scalar(Definition.ScalarType.t(), opts()) :: module()
def generate_scalar(%Definition.ScalarType{} = type, opts) do
namespace = Keyword.fetch!(opts, :namespace)
dir = Keyword.fetch!(opts, :dir)
quiet = Keyword.get(opts, :quiet, false)
mod = Module.concat(namespace, type.name)
assigns = [
mod: mod,
description: type.description
]
content = eval_template(type, assigns)
true = create_file(file_path(type, dir), content, force: true, quiet: quiet)
mod
end
@spec render_object_like(
Definition.ObjectType.t()
| Definition.InterfaceType.t()
| Definition.InputObjectType.t(),
opts()
) :: {bucket :: String.t(), module(), content :: String.t()}
def render_object_like(type, opts)
when is_struct(type, Definition.ObjectType)
when is_struct(type, Definition.InterfaceType)
when is_struct(type, Definition.InputObjectType) do
namespace = Keyword.fetch!(opts, :namespace)
mod = Module.concat(namespace, type.name)
fields =
Enum.flat_map(type.fields, fn field ->
List.wrap(build_object_field(field, opts))
end)
assigns = [
mod: mod,
description: type.description,
fields: fields
]
content = eval_template(type, assigns)
{object_bucket(type.name), mod, content}
end
# Soft cap on modules per file. Files exceeding this get split alphabetically.
# ~400 modules ≈ 5K–6K lines, still navigable in IDEs.
@max_modules_per_file 400
@spec write_object_like_buckets(
[{bucket :: String.t(), module(), content :: String.t()}],
opts()
) :: :ok
def write_object_like_buckets(rendered, opts) do
dir = Keyword.fetch!(opts, :dir)
quiet = Keyword.get(opts, :quiet, false)
rendered
|> Enum.group_by(
fn {bucket, _mod, _content} -> bucket end,
fn {_b, mod, c} -> {mod, c} end
)
|> Enum.flat_map(fn {bucket, items} -> split_bucket(bucket, items) end)
|> Enum.each(fn {file_name, contents} ->
file = Path.join(dir, "#{file_name}.ex")
true = create_file(file, Enum.join(contents, "\n"), force: true, quiet: quiet)
end)
:ok
end
# If a bucket exceeds the cap, recursively split its members alphabetically
# by the next character of the unprefixed module name until each shard fits.
defp split_bucket(bucket, items) when length(items) <= @max_modules_per_file do
[{bucket, Enum.map(items, fn {_mod, c} -> c end)}]
end
defp split_bucket(bucket, items) do
items
|> Enum.group_by(fn {mod, _c} -> alpha_key(short_name(mod), 0) end)
|> Enum.sort()
|> Enum.flat_map(fn {key, group} -> sub_split(key, group, 1) end)
|> merge_adjacent()
|> Enum.map(fn {keys, group} ->
{"#{bucket}_#{key_range(keys)}", Enum.map(group, fn {_mod, c} -> c end)}
end)
end
# Render keys as a compact range: ["a"] -> "a", ["a","b","c"] -> "a_c".
# Multi-char keys (after recursive splits) use first/last in lex order.
defp key_range(keys) do
sorted = Enum.sort(keys)
first = List.first(sorted)
last = List.last(sorted)
if first == last, do: first, else: "#{first}_#{last}"
end
# Recursively split until under cap; emit {[key], group} entries (key is one char).
defp sub_split(key, group, _depth) when length(group) <= @max_modules_per_file do
[{[key], group}]
end
defp sub_split(key, group, depth) do
group
|> Enum.group_by(fn {mod, _c} -> alpha_key(short_name(mod), depth) end)
|> Enum.sort()
|> Enum.flat_map(fn {sub_key, sub_group} ->
sub_split("#{key}#{sub_key}", sub_group, depth + 1)
end)
end
# Greedily merge adjacent (already sorted) entries while combined size fits cap.
defp merge_adjacent(entries) do
Enum.reduce(entries, [], fn {keys, group}, acc ->
case acc do
[{prev_keys, prev_group} | rest]
when length(prev_group) + length(group) <= @max_modules_per_file ->
[{prev_keys ++ keys, prev_group ++ group} | rest]
_other ->
[{keys, group} | acc]
end
end)
|> Enum.reverse()
end
# Lowercase nth character of the name, or "_" if shorter (terminates recursion).
defp alpha_key(name, index) do
case String.at(name, index) do
nil -> "_"
ch -> String.downcase(ch)
end
end
defp short_name(mod) do
mod |> Module.split() |> List.last()
end
# Bucket assignment by Shopify naming convention. Stable across versions.
defp object_bucket(name) when is_binary(name) do
cond do
String.ends_with?(name, "Connection") -> "connections"
String.ends_with?(name, "Edge") -> "edges"
String.ends_with?(name, "UserError") -> "user_errors"
String.ends_with?(name, "Payload") -> "payloads"
String.ends_with?(name, "Input") -> "inputs"
true -> "objects"
end
end
defp build_object_field(field, opts)
when is_struct(field, Definition.Field)
when is_struct(field, Definition.InputValue) do
{cardinality, type} = unwrap_object_field_type(field.type)
ecto_types = Keyword.fetch!(opts, :ecto_types)
case {cardinality, Map.fetch(ecto_types, type.name)} do
{:one, {:ok, {:field, type, opts}}} ->
{:loadable_field, field_name(field.name), type, opts}
{:many, {:ok, {:field, type, opts}}} ->
{:loadable_field, field_name(field.name), {:array, type}, opts}
{:one, {:ok, {:embeds, type, opts}}} ->
{:embeds_one, field_name(field.name), type, opts}
{:many, {:ok, {:embeds, type, opts}}} ->
{:embeds_many, field_name(field.name), type, opts}
{_cardinality, :error} ->
nil
end
end
defp unwrap_object_field_type(type, is_list? \\ false)
defp unwrap_object_field_type(%Definition.NonNullType{} = type, is_list?),
do: unwrap_object_field_type(type.type, is_list?)
defp unwrap_object_field_type(%Definition.ListType{} = type, _is_list?),
do: unwrap_object_field_type(type.type, true)
defp unwrap_object_field_type(%Definition.NamedType{} = type, is_list?),
do: if(is_list?, do: {:many, type}, else: {:one, type})
defp field_name(name) when is_binary(name) do
name |> Macro.underscore() |> String.to_atom()
end
defp file_path(%{name: name} = type, dir) when is_struct(type) do
type_name = Macro.underscore(name)
Path.join(dir, "#{type_name}.ex")
end
defp eval_template(type, assigns) do
type |> template_file_path() |> EEx.eval_file(assigns: assigns)
end
defp template_file_path(%Definition.ScalarType{} = type) do
type_name = Macro.underscore(type.name)
Path.join([schema_template_dir(), "scalars", "#{type_name}.eex"])
end
defp template_file_path(type)
when is_struct(type, Definition.ObjectType)
when is_struct(type, Definition.InterfaceType)
when is_struct(type, Definition.InputObjectType) do
Path.join(schema_template_dir(), "object_like.eex")
end
defp schema_template_dir do
Path.join(
:code.priv_dir(:ex_shopify_schema),
"graphql/templates/schemas/"
)
end
end