Current section

Files

Jump to
gql_builder lib gql.ex
Raw

lib/gql.ex

alias Absinthe.Language.{Document, OperationDefinition, SelectionSet}
alias Absinthe.Language.{Field, Argument, Variable, VariableDefinition}
alias Absinthe.Language.{NonNullType, NamedType, Fragment, InlineFragment, FragmentSpread}
alias Absinthe.Language.{
StringValue,
BooleanValue,
IntValue,
FloatValue,
EnumValue,
ListValue,
ObjectValue,
ObjectField,
NullValue
}
defmodule GQL do
@gql inspect(__MODULE__)
@moduledoc """
A composable query builder for dynamically constructing and manipulating
GraphQL queries, mutations, and subscriptions.
`#{@gql}` provides a programmatic way to build GraphQL documents as data
structures, similar to how `Ecto.Query` makes SQL queries composable. Instead
of working with static query strings, you can dynamically create, merge, and
transform GraphQL operations using a functional API.
## Basic Usage
Start with a new document and add fields:
iex> #{@gql}.new()
...> |> #{@gql}.name("contact")
...> |> #{@gql}.field(:user)
...> |> #{@gql}.field(:name, path: [:user])
...> |> #{@gql}.field(:email, path: [:user])
...> |> to_string()
\"\"\"
query contact {
user {
name
email
}
}
\"\"\"
## Composable Queries
Build queries from reusable components:
def base_user_fields(query) do
query
|> #{@gql}.field(:id)
|> #{@gql}.field(:name)
end
def with_posts(query) do
query
|> #{@gql}.field(:posts)
|> #{@gql}.field(:title, path: [:posts])
end
#{@gql}.new(field: :user)
|> base_user_fields()
|> with_posts()
The result is
query {
user
id
name
posts {
title
}
}
This composable approach allows you to build complex queries from simple,
testable building blocks, making it easier to maintain and reuse query logic
throughout your application.
## Working with Variables
Define variables for dynamic queries:
#{@gql}.new()
|> #{@gql}.variable(:id, type: "ID")
|> #{@gql}.field(:user, args: %{id: "$id"})
|> #{@gql}.field(:name, path: [:user])
|> #{@gql}.name(:GetUser)
The query built is this:
query GetUser($id: ID!) {
user(id: $id) {
name
}
}
## Parsing Existing Documents
Parse and manipulate existing GraphQL documents:
~GQL[query { user(id: 19) { id } }]
|> #{@gql}.field(:mailbox_size, path: [:user])
|> #{@gql}.type(:subscription)
It generates a subscription as follows:
subscription {
user(id: 19) {
id
mailbox_size
}
}
The `~GQL` sigil validates syntax at compile time while still allowing
runtime manipulation of the document structure.
"""
@doc """
`~GQL` sigil for creating syntax-validated GraphQL request documents.
### Examples:
iex> import #{inspect(__MODULE__)}, only: [sigil_GQL: 2]
iex> ~GQL[ query get_score($id: ID!) { user(id: $id) { name score { min max current } } } ]
...> |> to_string()
\"\"\"
query get_score($id: ID!) {
user(id: $id) {
name
score {
min
max
current
}
}
}
\"\"\"
"""
Code.ensure_loaded(Absinthe.Sigil)
if Kernel.function_exported?(Absinthe.Sigil, :sigil_GQL, 2) do
def sigil_GQL(graphql, opts) do
graphql
|> Absinthe.Sigil.sigil_GQL(opts)
|> parse()
end
else
IO.warn("Please upgrade Absinthe to `>= 1.9.0` in order to ~GQL to work.")
end
@doc """
A guard that checks if the argument is one of the `:query`, `:mutation` or
`:subscription` atoms.
iex> for i <- [:query, :mutation, :subscription, :reduction, 123, "hello"],
...> do: #{@gql}.is_operation(i)
[true, true, true, false, false, false]
"""
defguard is_operation(type) when type in [:query, :mutation, :subscription]
@base %Document{
definitions: [
%OperationDefinition{
operation: :query,
selection_set: %SelectionSet{selections: []}
}
]
}
@doc """
Initializes a new, empty #{@gql} document.
iex> #{@gql}.new(field: "x") |> to_string()
\"\"\"
query {
x
}
\"\"\"
Options can be used to populate the query:
iex> #{@gql}.new(name: "test", field: "__typename") |> to_string()
\"\"\"
query test {
__typename
}
\"\"\"
iex> #{@gql}.new(
...> field: {"posts", alias: "p", args: %{id: 42}},
...> field: {:title, alias: "t", path: ["p"]},
...> field: {:author, path: "p", alias: "a"}) |> to_string()
\"\"\"
query {
p: posts(id: 42) {
t: title
a: author
}
}
\"\"\"
The `fields` option allows nested field definitions:
iex> #{@gql}.new(field: {:posts, fields: [:title, {:author, fields: [:name, :email]}]}) |> to_string()
\"\"\"
query {
posts {
title
author {
name
email
}
}
}
\"\"\"
Inline fragments can also be added with the `fields` option:
iex> #{@gql}.new(
...> field: :x,
...> fragment: [type: :Author, path: "x", fields: [:name, {:email, alias: "mail"}]]) |> to_string()
\"\"\"
query {
x {
... on Author {
name
mail: email
}
}
}
\"\"\"
Field arguments can be compound datastructures:
iex> GQL.new(field: {:hello, args: %{where: %{id: 42}, indices: [4,7,8]}})
...> |> to_string()
\"\"\"
query {
hello(where: { id: 42 }, indices: [4, 7, 8])
}
\"\"\"
"""
def new(opts \\ []) do
Enum.reduce(opts, @base, fn {function, arg}, doc ->
args =
cond do
is_tuple(arg) -> Tuple.to_list(arg)
is_list(arg) and Keyword.keyword?(arg) -> [arg]
is_list(arg) -> arg
true -> List.wrap(arg)
end
arity = length(args) + 1
if Kernel.function_exported?(__MODULE__, function, arity) do
apply(__MODULE__, function, [doc | args])
else
raise ArgumentError, "Function #{function}/#{arity} not found."
end
end)
end
@doc """
Parses a GraphQL document into the internal representation.
iex> #{@gql}.parse("query { field }") |> to_string()
\"\"\"
query {
field
}
\"\"\"
"""
def parse(%Document{} = doc), do: doc
def parse("" <> str) do
with {:ok, tokens} <- Absinthe.Lexer.tokenize(str),
{:ok, parsed} <- :absinthe_parser.parse(tokens) do
parsed
end
end
@doc """
Parses a GraphQL document from a file into the internal representation.
iex> File.write!("/tmp/query.graphql", "query { posts { title author } }")
iex> #{@gql}.parse_file("/tmp/query.graphql") |> to_string()
\"\"\"
query {
posts {
title
author
}
}
\"\"\"
"""
def parse_file(filename) do
with {:ok, content} <- File.read(filename) do
parse(content)
end
end
@doc """
Sets the operation type of the document to :query, :mutation, or :subscription.
iex> #{@gql}.new(field: :field) |> #{@gql}.type(:subscription) |> to_string()
\"\"\"
subscription {
field
}
\"\"\"
"""
def type(doc, type) when is_operation(type) do
doc = parse(doc)
update_operation_definitions(doc, &%{&1 | operation: type})
end
@doc """
Assigns a name to the GraphQL operation for better logging and server-side observability.
iex> #{@gql}.new(field: :field) |> #{@gql}.name(:hello) |> to_string()
\"\"\"
query hello {
field
}
\"\"\"
"""
def name(doc, name) do
doc = parse(doc)
name = to_string(name)
update_operation_definitions(doc, &%{&1 | name: name})
end
@doc """
Adds a variable definition to the operation header with its type and an optional default value.
iex> #{@gql}.new()
...> |> #{@gql}.type(:mutation)
...> |> #{@gql}.variable(:id, type: ID, optional: true)
...> |> #{@gql}.variable(:key, type: Integer)
...> |> #{@gql}.variable(:name, default: "Joe")
...> |> #{@gql}.variable(:age, default: 42)
...> |> #{@gql}.field(:add_user, args: %{name: "$name", age: "$age", id: "$id"})
...> |> #{@gql}.field(:set_key, args: [key: "$key", value: "hello"])
...> |> to_string()
\"\"\"
mutation Mutation($id: ID, $key: Integer!, $name: String! = "Joe", $age: Integer! = 42) {
add_user(id: $id, name: $name, age: $age)
set_key(key: $key, value: "hello")
}
\"\"\"
"""
def variable(doc, name, opts \\ []) do
doc = parse(doc)
{guessed_type, default} = wrap_value(Keyword.get(opts, :default))
type = Keyword.get(opts, :type, guessed_type || "String")
type = %NamedType{name: to_type(type)}
optional = Keyword.get(opts, :optional, false)
%{
doc
| definitions:
for definition <- doc.definitions do
%{
definition
| variable_definitions:
definition.variable_definitions ++
[
%VariableDefinition{
variable: %Variable{name: to_string(name)},
type: if(optional, do: type, else: %NonNullType{type: type}),
default_value: default
}
],
name: definition.name || String.capitalize(to_string(definition.operation))
}
end
}
end
@doc """
Removes a variable definition from the operation header by its name.
iex> \"""
...> query hello($id: ID!, $semver: Boolean! = true) {
...> serverVersion(semver: $semver)
...> }
...> \"""
...> |> #{@gql}.remove_variable(:id)
...> |> to_string()
\"\"\"
query hello($semver: Boolean! = true) {
serverVersion(semver: $semver)
}
\"\"\"
"""
def remove_variable(doc, name) do
doc = parse(doc)
name = to_string(name)
update_operation_definitions(doc, fn definition ->
%{
definition
| variable_definitions:
Enum.reject(definition.variable_definitions, &match?(%{variable: %{name: ^name}}, &1))
}
end)
end
@doc """
Appends a field to the document.
iex> "query { __typename }" |> #{@gql}.field(:id) |> to_string()
"query {
__typename
id
}
"
The place of the new field is described by the `path` option. If the path
is not supplied or is an empty list, the field is placed in the root selection
set of the document.
iex> import #{@gql}
iex> new() |> field(:id, path: ["blogs", "posts"]) |> to_string()
"query {
blogs {
posts {
id
}
}
}
"
You can also add fields to named fragments by using the fragment name as the
first element of the path:
iex> import #{@gql}
iex> new()
...> |> field(:x)
...> |> fragment(name: :UserFields, type: :User)
...> |> field(:name, path: [:UserFields])
...> |> field(:email, path: [:UserFields])
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
x
}
fragment UserFields on User {
name
email
}
\"\"\"
The `fields` option allows you to specify subfields directly:
iex> import #{@gql}
iex> new() |> field(:foo, fields: [:bar, :baz]) |> to_string()
\"\"\"
query {
foo {
bar
baz
}
}
\"\"\"
Subfield definitions support the same options as the main field definition,
including `alias` and `args`:
iex> import #{@gql}
iex> new() |> field(:user, fields: [:id, {:name, alias: "fullName"}]) |> to_string()
\"\"\"
query {
user {
id
fullName: name
}
}
\"\"\"
iex> import #{@gql}
iex> new() |> field(:users, fields: [{:user, args: %{id: 42}, alias: "u"}, :name]) |> to_string()
\"\"\"
query {
users {
u: user(id: 42)
name
}
}
\"\"\"
The `path` option is not allowed in subfield definitions:
iex> import #{@gql}
iex> new() |> field(:foo, fields: [{:bar, path: [:baz]}])
** (ArgumentError) the `path` option is not allowed in subfield definitions
"""
def field(doc, name, opts \\ []) do
doc = parse(doc)
name = to_string(name)
alias = Keyword.get(opts, :alias)
path = Keyword.get(opts, :path, []) |> List.wrap()
args = Keyword.get(opts, :args, [])
subfields = Keyword.get(opts, :fields, [])
inline_fragments = Keyword.get(opts, :spread_on, [])
sread_fragment_names = Keyword.get(opts, :spread, [])
field = %Field{name: name, alias: alias && to_string(alias), arguments: arguments(args)}
{target_filter, field_path} = resolve_path_target(doc, path)
optic = build_navigation_optic(target_filter, field_path)
identifier = field_identifier(%{alias: alias, name: name})
sub_path = path ++ [identifier]
doc
|> update_in(optic, fn selection_list -> (selection_list || []) ++ [field] end)
|> add_subfields(subfields, sub_path)
|> spread_fragments(sread_fragment_names, sub_path)
|> add_inline_fragments(inline_fragments, sub_path)
end
# Helper function to add subfields
defp add_subfields(doc, fields, path) do
Enum.reduce(fields, doc, fn field_def, acc_doc ->
add_subfield(acc_doc, field_def, path)
end)
end
defp add_subfield(doc, {subfield_name, subfield_opts}, path) when is_list(subfield_opts) do
if Keyword.has_key?(subfield_opts, :path) do
raise ArgumentError, "the `path` option is not allowed in subfield definitions"
end
field(doc, subfield_name, Keyword.put(subfield_opts, :path, path))
end
defp add_subfield(doc, subfield_name, path) do
field(doc, subfield_name, path: path)
end
# Helper function to add a fragment names
defp spread_fragments(doc, frag_names, path) do
Enum.reduce(frag_names, doc, fn frag_name, acc_doc ->
GQL.spread_fragment(acc_doc, frag_name, path: path)
end)
end
# Helper function to add a inline fragments
defp add_inline_fragments(doc, frags, path) do
Enum.reduce(frags, doc, fn frag_def, acc_doc ->
add_inline_fragment(acc_doc, frag_def, path)
end)
end
defp add_inline_fragment(doc, {frag_type, frag_opts}, path) when is_list(frag_opts) do
if Keyword.has_key?(frag_opts, :path) do
raise ArgumentError, "the `path` option is not allowed in subfrag definitions"
end
opts =
frag_opts
|> Keyword.put(:type, frag_type)
|> Keyword.put(:path, path)
fragment(doc, opts)
end
defp add_inline_fragment(doc, frag_type, path) do
fragment(doc, type: frag_type, path: path)
end
# Helper to resolve path into target filter and field path
defp resolve_path_target(doc, path) do
case path do
[first | rest] ->
first_str = to_string(first)
has_fragment =
Enum.any?(doc.definitions, fn
%Fragment{name: ^first_str} -> true
_ -> false
end)
if has_fragment do
{Access.filter(&match?(%Fragment{name: ^first_str}, &1)), rest}
else
{Access.filter(&match?(%OperationDefinition{}, &1)), path}
end
[] ->
{Access.filter(&match?(%OperationDefinition{}, &1)), []}
end
end
# Parse path element into normalized {name, alias, args} tuple or inline fragment specification
defp parse_path_element({nil, opts}) when is_list(opts) do
{:inline_fragment, Keyword.get(opts, :type)}
end
defp parse_path_element({name, opts}) when is_list(opts) do
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
end
defp parse_path_element(name) do
{to_string(name), nil, []}
end
# Helper to build navigation optic from target filter and field path
defp build_navigation_optic(target_filter, field_path) do
[
access_key(:definitions, nil, []),
target_filter,
for path_element <- field_path do
build_path_navigation(path_element)
end,
access_key(:selection_set, nil, %SelectionSet{}),
Access.key(:selections, [])
]
|> List.flatten()
end
# Helper to build navigation for a single path element
defp build_path_navigation({nil, opts}) when is_list(opts) do
type = Keyword.get(opts, :type)
type_str = type && to_string(type)
filter =
if type do
Access.filter(&match?(%InlineFragment{type_condition: %NamedType{name: ^type_str}}, &1))
else
Access.filter(&match?(%InlineFragment{type_condition: nil}, &1))
end
[access_key(:selection_set, nil, %SelectionSet{}), access_key(:selections, [], []), filter]
end
defp build_path_navigation(path_element) do
{name, alias_name, args} = parse_path_element(path_element)
field = %Field{
name: name,
alias: alias_name && to_string(alias_name),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, nil, []),
access_or_create_field(name, field)
]
end
# Custom access function that creates a field if it doesn't exist
defp access_or_create_field(name, default_field) do
fn
:get, data, next when is_list(data) ->
data
|> Enum.find(&match_field(&1, name))
|> case do
nil -> next.(default_field)
field -> next.(field)
end
:get_and_update, data, next when is_list(data) ->
case Enum.find_index(data, &match_field(&1, name)) do
nil ->
handle_field_creation(data, default_field, next)
index ->
handle_field_update(data, index, next)
end
end
end
defp handle_field_creation(data, default_field, next) do
case next.(default_field) do
{get, updated_field} -> {get, data ++ [updated_field]}
:pop -> {default_field, data}
end
end
defp handle_field_update(data, index, next) do
case next.(Enum.at(data, index)) do
{get, updated_field} -> {get, List.replace_at(data, index, updated_field)}
:pop -> {Enum.at(data, index), List.delete_at(data, index)}
end
end
# Helper to get field identifier (alias or name)
defp field_identifier(%{alias: alias_val, name: _name_val}) when not is_nil(alias_val),
do: alias_val
defp field_identifier(%{name: name_val}), do: name_val
defp field_identifier(_), do: nil
defp match_field(%Field{} = f, name) do
# If the field has an alias, only match on alias
# If no alias, match on name
if f.alias, do: f.alias == name, else: f.name == name
end
defp match_field(_, _), do: false
@doc """
Attaches an argument to a field identified by its path.
iex> "query { hello }"
...> |> #{@gql}.argument(:who, path: ["hello"], value: "World!")
...> |> to_string()
\"\"\"
query {
hello(who: "World!")
}
\"\"\"
"""
def argument(doc, name, opts \\ []) do
doc = parse(doc)
name = to_string(name)
path = Keyword.fetch!(opts, :path) |> List.wrap()
value = Keyword.fetch!(opts, :value)
{_type, wrapped_value} = wrap_value(value)
argument = %Argument{name: name, value: wrapped_value}
optic = build_field_optic(path, :arguments)
update_in(doc, optic, fn argument_list -> (argument_list || []) ++ [argument] end)
end
@doc """
Replaces a field or subfield at the specified path with a completely new field definition.
This function removes the existing field and adds a new one with the same name but potentially
different arguments, alias, or sub-selections. The field is replaced in-place at the specified path.
iex> "query { user { id name email } }"
...> |> #{@gql}.replace_field(:user, args: %{id: 42})
...> |> to_string()
\"\"\"
query {
user(id: 42) {
id
name
email
}
}
\"\"\"
Nested fields can be replaced by providing a path:
iex> "query { user { profile { bio avatar } } }"
...> |> #{@gql}.replace_field(:profile, path: ["user"], alias: "userProfile")
...> |> to_string()
\"\"\"
query {
user {
userProfile: profile {
bio
avatar
}
}
}
\"\"\"
"""
def replace_field(doc, name, opts \\ []) do
doc = parse(doc)
name = to_string(name)
path = Keyword.get(opts, :path, []) |> List.wrap()
alias_name = Keyword.get(opts, :alias)
args = Keyword.get(opts, :args, [])
optic = build_field_optic(path, :selections)
update_in(doc, optic, fn selections ->
Enum.map(selections || [], &replace_field_if_match(&1, name, alias_name, args))
end)
end
defp replace_field_if_match(%Field{} = field, name, alias_name, args) do
if match_field(field, name) do
%{field | alias: alias_name && to_string(alias_name), arguments: arguments(args)}
else
field
end
end
defp replace_field_if_match(other, _name, _alias_name, _args), do: other
@doc """
Removes a field and all of its associated sub-selections from the document.
iex> "query { apple { foo bar baz } banana }"
...> |> #{@gql}.remove_field(:banana)
...> |> #{@gql}.remove_field(:baz, path: ["apple"])
...> |> to_string()
\"\"\"
query {
apple {
foo
bar
}
}
\"\"\"
"""
def remove_field(doc, name, opts \\ []) do
doc = parse(doc)
name = to_string(name)
path = Keyword.get(opts, :path, []) |> List.wrap()
optic = build_field_optic(path, :selections)
update_in(doc, optic, fn selections ->
Enum.reject(selections || [], &match_field(&1, name))
end)
end
@doc """
Updates the value of an existing argument on a field located at the given path.
This function replaces the value of an existing argument while keeping the argument name.
If the argument doesn't exist, it will be added to the field.
iex> "query { user(id: 42) { name } }"
...> |> #{@gql}.replace_argument(:id, path: ["user"], value: 99)
...> |> to_string()
\"\"\"
query {
user(id: 99) {
name
}
}
\"\"\"
You can update arguments with different types of values:
iex> "query { posts(limit: 10, published: true) { title } }"
...> |> #{@gql}.replace_argument(:limit, path: ["posts"], value: 20)
...> |> #{@gql}.replace_argument(:published, path: ["posts"], value: false)
...> |> to_string()
\"\"\"
query {
posts(limit: 20, published: false) {
title
}
}
\"\"\"
"""
def replace_argument(doc, name, opts \\ []) do
path = Keyword.fetch!(opts, :path)
doc
|> remove_argument(name, path)
|> argument(name, opts)
end
@doc """
Deletes a specific argument from a field located at the given path.
iex> "query { user(id: 42, name: \\"John\\") { email } }"
...> |> #{@gql}.remove_argument(:name, ["user"])
...> |> to_string()
\"\"\"
query {
user(id: 42) {
email
}
}
\"\"\"
"""
def remove_argument(doc, key, path) do
doc = parse(doc)
path = List.wrap(path)
key = to_string(key)
optic = build_field_optic(path, :arguments)
update_in(doc, optic, fn arguments ->
Enum.reject(arguments || [], &(&1.name == key))
end)
end
@doc """
Adds a directive, such as @include or @skip, to a field or to the operation
itself at the specified path.
Directives are annotations that can be attached to fields or operations to
provide additional metadata or modify execution behavior. Common directives
include `@include`, `@skip`, and `@deprecated`.
To add a directive to the operation itself (the root
query/mutation/subscription), use an empty path `[]`. This allows you to
write queries like `query @directive { ... }`.
## Examples
Adding a directive to the operation itself (root):
iex> "query { user { name } }"
...> |> #{@gql}.directive("cached", [])
...> |> to_string()
\"\"\"
query @cached {
user {
name
}
}
\"\"\"
Adding a directive with arguments to the root operation:
iex> #{@gql}.new(field: :user)
...> |> #{@gql}.field(:name, path: [:user])
...> |> #{@gql}.directive("rateLimit", [], %{max: 100})
...> |> to_string()
\"\"\"
query @rateLimit(max: 100) {
user {
name
}
}
\"\"\"
Adding an @include directive with a condition to a field:
iex> "query { user { name email } }"
...> |> #{@gql}.directive("include", ["user"], %{if: "$showUser"})
...> |> to_string()
\"\"\"
query {
user @include(if: $showUser) {
name
email
}
}
\"\"\"
Adding a @skip directive to a nested field:
iex> "query { user { name email } }"
...> |> #{@gql}.directive("skip", ["user", "email"], %{if: "$hideEmail"})
...> |> to_string()
\"\"\"
query {
user {
name
email @skip(if: $hideEmail)
}
}
\"\"\"
Adding a directive without arguments:
iex> "query { deprecatedField }"
...> |> #{@gql}.directive(:deprecated, ["deprecatedField"])
...> |> to_string()
\"\"\"
query {
deprecatedField @deprecated
}
\"\"\"
"""
def directive(doc, name, path, directive_args \\ []) do
alias Absinthe.Language.Directive
doc = parse(doc)
path = List.wrap(path)
name = to_string(name)
directive = %Directive{name: name, arguments: arguments(directive_args)}
case path do
[] ->
# Empty path means add directive to the operation itself
update_operation_definitions(doc, fn op_def ->
%{op_def | directives: (op_def.directives || []) ++ [directive]}
end)
_ ->
# Non-empty path means add directive to a field
optic = build_field_optic(path, :directives)
update_in(doc, optic, fn directive_list -> (directive_list || []) ++ [directive] end)
end
end
@doc """
Creates either a named fragment definition or adds an inline fragment to a selection set.
This function has two modes of operation based on the options provided:
- **Named fragments** (when `name` and `type` are provided): Creates a reusable fragment
definition that can be spread across multiple queries using `spread_fragment/3`.
- **Inline fragments** (when `path` and `type` are provided): Adds an inline fragment
for handling union or interface types at a specific path.
## Named Fragment Examples
Creating a basic named fragment on a User type:
iex> #{@gql}.new(field: "x")
...> |> #{@gql}.fragment(name: :UserFields, type: :User)
...> |> #{@gql}.field("y", path: [:UserFields])
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
x
}
fragment UserFields on User {
y
}
\"\"\"
Creating multiple named fragments:
iex> #{@gql}.new(field: "x")
...> |> #{@gql}.fragment(name: :BasicUser, type: :User)
...> |> #{@gql}.fragment(name: :PostInfo, type: :Post)
...> |> #{@gql}.field("y", path: :BasicUser)
...> |> #{@gql}.field("z", path: :PostInfo)
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
x
}
fragment BasicUser on User {
y
}
fragment PostInfo on Post {
z
}
\"\"\"
## Inline Fragment Examples
Adding an inline fragment to handle a union type:
iex> #{@gql}.new()
...> |> #{@gql}.field(:search, args: %{term: "elixir"})
...> |> #{@gql}.fragment(type: :User, path: [:search])
...> |> #{@gql}.field(:name, path: [:search, {nil, type: :User}])
...> |> #{@gql}.field(:email, path: [:search, {nil, type: :User}])
...> |> #{@gql}.fragment(type: :Post, path: [:search])
...> |> #{@gql}.field(:title, path: [:search, {nil, type: :Post}])
...> |> #{@gql}.field(:content, path: [:search, {nil, type: :Post}])
...> |> to_string()
\"\"\"
query {
search(term: "elixir") {
... on User {
name
email
}
... on Post {
title
content
}
}
}
\"\"\"
Adding an inline fragment without a type condition (for interfaces):
iex> #{@gql}.new()
...> |> #{@gql}.field(:node, args: %{id: "123"})
...> |> #{@gql}.field(:id, path: [:node])
...> |> #{@gql}.fragment(type: nil, path: [:node])
...> |> #{@gql}.field(:__typename, path: [:node, {nil, type: nil}])
...> |> to_string()
\"\"\"
query {
node(id: "123") {
id
... {
__typename
}
}
}
\"\"\"
The `type` option can also be omitted entirely for grouping fields:
iex> #{@gql}.new()
...> |> #{@gql}.field(:user)
...> |> #{@gql}.field(:id, path: [:user])
...> |> #{@gql}.fragment(path: [:user], fields: [:name, :email])
...> |> to_string()
\"\"\"
query {
user {
id
... {
name
email
}
}
}
\"\"\"
The `fields` option allows you to specify subfields directly within the inline fragment:
iex> #{@gql}.new()
...> |> #{@gql}.field(:search, args: %{term: "elixir"})
...> |> #{@gql}.fragment(type: :User, path: [:search], fields: [:name, :email])
...> |> #{@gql}.fragment(type: :Post, path: [:search], fields: [:title, :content])
...> |> to_string()
\"\"\"
query {
search(term: "elixir") {
... on User {
name
email
}
... on Post {
title
content
}
}
}
\"\"\"
Subfield definitions support the same options as the main field definition,
including `alias` and `args`:
iex> #{@gql}.new()
...> |> #{@gql}.field(:search)
...> |> #{@gql}.fragment(type: :User, path: [:search], fields: [:id, {:name, alias: "fullName"}])
...> |> to_string()
\"\"\"
query {
search {
... on User {
id
fullName: name
}
}
}
\"\"\"
"""
def fragment(doc, opts \\ []) do
doc = parse(doc)
subfields = Keyword.get(opts, :fields, [])
sub_inline_fragments = Keyword.get(opts, :spread_on, [])
sread_fragment_names = Keyword.get(opts, :spread, [])
cond do
# Named fragment mode: name and type are provided
Keyword.has_key?(opts, :name) ->
path = Keyword.get(opts, :path, []) |> List.wrap()
name = Keyword.fetch!(opts, :name)
type = Keyword.fetch!(opts, :type)
fragment = %Fragment{
name: to_string(name),
type_condition: %NamedType{name: to_string(type)},
directives: [],
selection_set: %SelectionSet{selections: []}
}
sub_path = path ++ [name]
%{doc | definitions: doc.definitions ++ [fragment]}
|> add_subfields(subfields, sub_path)
|> spread_fragments(sread_fragment_names, sub_path)
|> add_inline_fragments(sub_inline_fragments, sub_path)
# Inline fragment mode: path is provided
Keyword.has_key?(opts, :path) ->
path = Keyword.get(opts, :path, []) |> List.wrap()
type = Keyword.get(opts, :type)
type_condition = type && %NamedType{name: to_string(type)}
inline_fragment = %InlineFragment{
type_condition: type_condition,
directives: [],
selection_set: %SelectionSet{selections: []}
}
optic = build_field_optic(path, :selections)
sub_path = path ++ [{nil, type: type}]
doc
|> update_in(optic, fn selection_list -> (selection_list || []) ++ [inline_fragment] end)
|> add_subfields(subfields, sub_path)
|> spread_fragments(sread_fragment_names, sub_path)
|> add_inline_fragments(sub_inline_fragments, sub_path)
true ->
raise ArgumentError,
"fragment/2 requires either :name and :type options (for named fragments) or :path option (for inline fragments)"
end
end
@doc """
Deletes a fragment from the document by its name.
## Examples
Removing a fragment:
iex> "query { user { id } }"
...> |> #{@gql}.fragment(name: :UserFields, type: :User)
...> |> #{@gql}.fragment(name: :PostInfo, type: :Post)
...> |> #{@gql}.remove_fragment(:UserFields)
...> |> #{@gql}.field("x", path: :PostInfo)
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
user {
id
}
}
fragment PostInfo on Post {
x
}
\"\"\"
Removing a non-existent fragment does nothing:
iex> "query { user { id } }"
...> |> #{@gql}.fragment(name: :UserFields, type: :User)
...> |> #{@gql}.remove_fragment(:NonExistent)
...> |> #{@gql}.field("x", path: :UserFields)
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
user {
id
}
}
fragment UserFields on User {
x
}
\"\"\"
"""
def remove_fragment(doc, name) do
doc = parse(doc)
name = to_string(name)
%{
doc
| definitions:
Enum.reject(doc.definitions, fn
%Fragment{name: ^name} -> true
_ -> false
end)
}
end
@doc """
Spreads a named fragment into a selection set at the specified path.
Fragment spreads allow you to reference and reuse a named fragment definition
within your query. The fragment must be defined elsewhere in the document using
the `fragment/3` function.
## Examples
Spreading a fragment at the root level:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(name: :UserFields, type: :User)
...> |> #{@gql}.field(:name, path: [:UserFields])
...> |> #{@gql}.field(:email, path: [:UserFields])
...> |> #{@gql}.field(:user)
...> |> #{@gql}.spread_fragment(:UserFields, path: [:user])
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
user {
...UserFields
}
}
fragment UserFields on User {
name
email
}
\"\"\"
Spreading a fragment in a nested field:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(name: :ContactInfo, type: :User)
...> |> #{@gql}.field(:email, path: [:ContactInfo])
...> |> #{@gql}.field(:phone, path: [:ContactInfo])
...> |> #{@gql}.field(:organization)
...> |> #{@gql}.field(:users, path: [:organization])
...> |> #{@gql}.spread_fragment(:ContactInfo, path: [:organization, :users])
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
organization {
users {
...ContactInfo
}
}
}
fragment ContactInfo on User {
email
phone
}
\"\"\"
"""
def spread_fragment(doc, name, opts \\ []) do
doc = parse(doc)
name = to_string(name)
path = Keyword.get(opts, :path, []) |> List.wrap()
fragment_spread = %FragmentSpread{
name: name,
directives: []
}
{target_filter, field_path} = resolve_path_target(doc, path)
optic = build_navigation_optic(target_filter, field_path)
update_in(doc, optic, fn selection_list -> (selection_list || []) ++ [fragment_spread] end)
end
@doc """
Inlines all fragment spreads into the main selection set for simplified document structure.
This function replaces all fragment spreads (`...FragmentName`) with the actual fields
from the fragment definition, then removes the fragment definitions from the document.
This is useful when you want to convert a document with named fragments into a single
inline query without separate fragment definitions.
## Examples
Inlining a simple fragment:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(name: :UserFields, type: :User)
...> |> #{@gql}.field(:name, path: [:UserFields])
...> |> #{@gql}.field(:email, path: [:UserFields])
...> |> #{@gql}.field(:user)
...> |> #{@gql}.spread_fragment(:UserFields, path: [:user])
...> |> #{@gql}.inline_fragments()
...> |> to_string()
\"\"\"
query {
user {
name
email
}
}
\"\"\"
Inlining multiple fragments:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(name: :BasicInfo, type: :User)
...> |> #{@gql}.field(:id, path: [:BasicInfo])
...> |> #{@gql}.field(:name, path: [:BasicInfo])
...> |> #{@gql}.fragment(name: :ContactInfo, type: :User)
...> |> #{@gql}.field(:email, path: [:ContactInfo])
...> |> #{@gql}.field(:phone, path: [:ContactInfo])
...> |> #{@gql}.field(:user)
...> |> #{@gql}.spread_fragment(:BasicInfo, path: [:user])
...> |> #{@gql}.spread_fragment(:ContactInfo, path: [:user])
...> |> #{@gql}.inline_fragments()
...> |> to_string()
\"\"\"
query {
user {
id
name
email
phone
}
}
\"\"\"
"""
def inline_fragments(doc) do
doc = parse(doc)
# Build a map of fragment name -> fragment definition for quick lookup
fragment_map =
doc.definitions
|> Enum.filter(&match?(%Fragment{}, &1))
|> Map.new(fn %Fragment{name: name} = fragment -> {name, fragment} end)
# Process all operation definitions to replace fragment spreads
inlined_definitions =
for definition <- doc.definitions do
case definition do
%OperationDefinition{} = op_def ->
%{op_def | selection_set: inline_selection_set(op_def.selection_set, fragment_map)}
%Fragment{} ->
# Skip fragment definitions - they'll be removed
nil
end
end
|> Enum.reject(&is_nil/1)
%{doc | definitions: inlined_definitions}
end
# Recursively inline fragment spreads in a selection set
defp inline_selection_set(nil, _fragment_map), do: nil
defp inline_selection_set(%SelectionSet{selections: selections} = selection_set, fragment_map) do
inlined_selections =
selections
|> Enum.flat_map(fn
%FragmentSpread{name: name} ->
# Replace fragment spread with the fragment's selections
case Map.get(fragment_map, name) do
%Fragment{selection_set: fragment_selection_set} ->
# Recursively inline any nested fragment spreads
inlined_fragment_set = inline_selection_set(fragment_selection_set, fragment_map)
inlined_fragment_set.selections
nil ->
# Fragment not found, keep the spread as-is
[%FragmentSpread{name: name}]
end
%Field{selection_set: field_selection_set} = field ->
# Recursively process nested selections in fields
[%{field | selection_set: inline_selection_set(field_selection_set, fragment_map)}]
%InlineFragment{selection_set: inline_fragment_set} = inline_fragment ->
# Recursively process selections in inline fragments
[
%{
inline_fragment
| selection_set: inline_selection_set(inline_fragment_set, fragment_map)
}
]
other ->
[other]
end)
%{selection_set | selections: inlined_selections}
end
@doc """
Automatically injects the __typename field into all object selections.
iex> \"""
...> query {
...> apple {
...> foo
...> bar {
...> baz
...> }
...> }
...> }
...> \"""
...> |> #{@gql}.inject_typenames()
...> |> to_string()
\"\"\"
query {
apple {
foo
bar {
baz
__typename
}
__typename
}
__typename
}
\"\"\"
"""
def inject_typenames(doc) do
doc = parse(doc)
# Inject typenames into operation definitions
doc =
for path <- operation_paths(doc), reduce: doc do
doc -> field(doc, "__typename", path: path)
end
# Inject typenames into fragment definitions
for path <- fragment_paths(doc), reduce: doc do
doc -> field(doc, "__typename", path: path)
end
end
@doc """
Inlines the given variables into the document.
iex> "query Q($id: ID!) { get(id: $id) { name } }"
...> |> #{@gql}.inline_variables(%{id: 42})
...> |> to_string()
\"\"\"
query Q {
get(id: 42) {
name
}
}
\"\"\"
"""
def inline_variables(doc, %{} = args) do
for {variable, value} <- args, reduce: doc do
doc ->
doc
|> remove_variable(variable)
|> substitute_variable(variable, value)
end
end
@doc """
Merges two GraphQL documents by combining their variables and fields.
When operation types match (both query, mutation, or subscription), the result
is a single document containing:
- All variable definitions from both documents (deduplicated by name)
- All top-level fields from both documents (deduplicated by name and arguments)
When operation types don't match, the result contains separate definitions
for each operation type.
## Examples
Merging documents with matching operation types:
iex> doc1 = "query { user { id } }"
iex> doc2 = "query { posts { title } }"
iex> #{@gql}.merge(doc1, doc2) |> to_string()
\"\"\"
query {
user {
id
}
posts {
title
}
}
\"\"\"
Merging documents with variables (deduplicates by name):
iex> doc1 = "query Q($id: ID!) { user(id: $id) { name } }"
iex> doc2 = "query Q($id: ID!) { posts { title } }"
iex> #{@gql}.merge(doc1, doc2) |> to_string()
\"\"\"
query Q($id: ID!) {
user(id: $id) {
name
}
posts {
title
}
}
\"\"\"
Deduplicating identical fields:
iex> doc1 = "query { user { id } }"
iex> doc2 = "query { user { name } }"
iex> #{@gql}.merge(doc1, doc2) |> to_string()
\"\"\"
query {
user {
id
name
}
}
\"\"\"
Merging documents with different operation types:
iex> doc1 = "query { user { id } }"
iex> doc2 = "mutation { createUser { id } }"
iex> #{@gql}.merge(doc1, doc2)
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
user {
id
}
}
mutation {
createUser {
id
}
}
\"\"\"
"""
def merge(doc, other) do
{doc, other} = {parse(doc), parse(other)}
grouped = Enum.group_by(doc.definitions ++ other.definitions, & &1.operation)
%{
doc
| definitions:
for {_operation, definitions} <- grouped do
merge_definitions(definitions)
end
}
end
### Helpers
# Helper to update operation definitions
defp update_operation_definitions(doc, fun) do
%{
doc
| definitions:
for definition <- doc.definitions do
case definition do
%OperationDefinition{} = op_def -> fun.(op_def)
other -> other
end
end
}
end
# Build a common optic for navigating to a field property (arguments, directives, selections)
defp build_field_optic(path, target_key) do
[
access_key(:definitions, nil, []),
Access.all(),
for path_element <- path do
{name, alias_name, args} = parse_path_element(path_element)
field = %Field{
name: name,
alias: alias_name && to_string(alias_name),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(fn selection ->
alias_val = Map.get(selection, :alias)
name_val = Map.get(selection, :name)
# If has alias, only match on alias; otherwise match on name
if alias_val, do: alias_val == name, else: name_val == name
end)
]
end,
case target_key do
:selections ->
[access_key(:selection_set, nil, %SelectionSet{}), Access.key(:selections, [])]
_ ->
Access.key(target_key, [])
end
]
|> List.flatten()
end
defp merge_definitions([single]), do: single
defp merge_definitions(definitions) do
# Take the first definition as base
[base | _rest] = definitions
# Merge all variable definitions (deduplicate by variable name)
all_variables =
Enum.flat_map(definitions, & &1.variable_definitions)
|> Enum.uniq_by(fn %{variable: %{name: name}} -> name end)
# Merge all top-level fields (deduplicate by name and arguments)
all_selections =
Enum.flat_map(definitions, fn definition ->
definition.selection_set.selections
end)
|> deduplicate_fields()
# Build merged definition
%{
base
| variable_definitions: all_variables,
selection_set: %{base.selection_set | selections: all_selections}
}
end
defp deduplicate_fields(fields) do
# Two fields are considered identical if they have the same:
# - name (or alias if present)
# - arguments (both names and values)
# We preserve order by processing fields left-to-right
{result, _seen} =
Enum.reduce(fields, {[], %{}}, fn field, {acc, seen} ->
identifier = field_identifier(field)
args_signature =
field.arguments
|> Enum.map(fn arg -> {arg.name, inspect(arg.value)} end)
|> Enum.sort()
key = {identifier, args_signature}
case Map.get(seen, key) do
nil ->
# First time seeing this field, add it to result
{acc ++ [field], Map.put(seen, key, length(acc))}
index ->
# We've seen this field before, merge subfields
{merge_duplicate_field(acc, index, field), seen}
end
end)
result
end
defp merge_duplicate_field(acc, index, field) do
existing_field = Enum.at(acc, index)
# Collect all subfield selections
existing_subfields =
case existing_field.selection_set do
nil -> []
%{selections: selections} -> selections
end
new_subfields =
case field.selection_set do
nil -> []
%{selections: selections} -> selections
end
# Recursively deduplicate the merged subfields
merged_subfields = deduplicate_fields(existing_subfields ++ new_subfields)
# Update the field at the original position
updated_field =
case merged_subfields do
[] ->
existing_field
_ ->
%{existing_field | selection_set: %SelectionSet{selections: merged_subfields}}
end
List.replace_at(acc, index, updated_field)
end
defp wrap_value(nil), do: {nil, nil}
defp wrap_value(:null), do: {"NullValue", %NullValue{}}
defp wrap_value(int) when is_integer(int), do: {"Integer", %IntValue{value: int}}
defp wrap_value(float) when is_float(float), do: {"Float", %FloatValue{value: float}}
defp wrap_value(bool) when is_boolean(bool), do: {"Boolean", %BooleanValue{value: bool}}
defp wrap_value(atom) when is_atom(atom), do: {nil, %EnumValue{value: atom}}
defp wrap_value("$" <> name), do: {nil, %Variable{name: name}}
defp wrap_value("" <> string), do: {"String", %StringValue{value: string}}
defp wrap_value(%{} = map) do
{nil,
%ObjectValue{
fields:
for {name, value} <- map do
{_, value} = wrap_value(value)
%ObjectField{name: to_string(name), value: value}
end
}}
end
defp wrap_value(list) when is_list(list) do
{types, values} = Enum.unzip(Enum.map(list, &wrap_value(&1)))
[type] = Enum.uniq(types)
{"[#{type}!]", %ListValue{values: values}}
end
defp to_type(atom) when is_atom(atom) do
first_grapheme = atom |> inspect() |> String.graphemes() |> List.first()
if first_grapheme == ":" do
to_string(atom)
else
inspect(atom)
end
end
defp to_type(str) when is_binary(str), do: str
defp access_key(key, src, default) do
fn
:get, data, next ->
next.(substitute(Map.get(data, key, default), src, default))
:get_and_update, data, next ->
value = substitute(Map.get(data, key, default), src, default)
case next.(value) do
{get, update} -> {get, Map.put(data, key, update)}
:pop -> {value, Map.delete(data, key)}
end
end
end
defp substitute(value, src, dst) do
if value == src, do: dst, else: value
end
defp substitute_variable(doc, variable, value) do
doc = parse(doc)
variable_name = to_string(variable)
{_type, wrapped_value} = wrap_value(value)
update_operation_definitions(doc, fn definition ->
%{
definition
| selection_set:
substitute_in_selection_set(definition.selection_set, variable_name, wrapped_value)
}
end)
end
defp substitute_in_selection_set(nil, _variable_name, _value), do: nil
defp substitute_in_selection_set(
%SelectionSet{selections: selections} = selection_set,
variable_name,
value
) do
%{
selection_set
| selections:
for selection <- selections do
substitute_in_field(selection, variable_name, value)
end
}
end
defp substitute_in_field(%Field{} = field, variable_name, value) do
%{
field
| arguments: substitute_in_arguments(field.arguments, variable_name, value),
selection_set: substitute_in_selection_set(field.selection_set, variable_name, value)
}
end
defp substitute_in_arguments(arguments, variable_name, value) do
for argument <- arguments do
%{argument | value: substitute_in_value(argument.value, variable_name, value)}
end
end
defp substitute_in_value(%Variable{name: name}, variable_name, value)
when name == variable_name do
value
end
defp substitute_in_value(%ListValue{values: values} = list_value, variable_name, value) do
%{list_value | values: Enum.map(values, &substitute_in_value(&1, variable_name, value))}
end
defp substitute_in_value(other_value, _variable_name, _value), do: other_value
defp arguments(args) do
for {name, value} <- args do
{_type, value} = wrap_value(value)
%Argument{name: to_string(name), value: value}
end
end
# Get all paths within operation definitions (queries, mutations, subscriptions)
defp operation_paths(doc) do
doc = parse(doc)
doc.definitions
|> Enum.filter(&match?(%OperationDefinition{}, &1))
|> Enum.flat_map(fn definition ->
_paths(definition.selection_set)
end)
|> Enum.uniq()
end
# Get all paths within fragment definitions
defp fragment_paths(doc) do
doc = parse(doc)
doc.definitions
|> Enum.filter(&match?(%Fragment{}, &1))
|> Enum.flat_map(fn %Fragment{name: name, selection_set: selection_set} ->
_paths(selection_set)
|> Enum.map(fn path -> [name | path] end)
end)
|> Enum.uniq()
end
defp _paths(nil), do: []
defp _paths(%SelectionSet{selections: selections}) do
nested_paths =
selections
|> Enum.map(&selection_to_paths/1)
|> Enum.flat_map(& &1)
[[] | nested_paths]
end
defp selection_to_paths(%Field{selection_set: selection_set} = selection)
when not is_nil(selection_set) do
identifier = field_identifier(selection)
for path <- _paths(selection_set) do
[identifier | path]
end
end
defp selection_to_paths(_selection) do
# Skip InlineFragments, FragmentSpreads, and fields without selection sets
# Inline fragments are not navigable by field paths, so we can't inject into them
[]
end
end
defimpl String.Chars, for: Document do
@doc """
Serializes the abstract document structure into a GraphQL query string.
"""
def to_string(doc), do: inspect(doc, limit: :infinity, pretty: true, structs: true)
end
defimpl List.Chars, for: Document do
def to_charlist(doc) do
doc
|> to_string()
|> Kernel.to_charlist()
end
end