Current section
Files
Jump to
Current section
Files
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,
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 {
email
name
}
}
\"\"\"
## 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 {
posts {
title
}
name
id
user
}
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) {
mailbox_size
id
}
}
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
}
}
}
\"\"\"
"""
def sigil_GQL(graphql, opts) do
case Absinthe.Sigil.sigil_GQL(graphql, opts) do
{:error, _} = error -> error
str -> parse(str)
end
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() |> to_string()
\"\"\"
query {
}
\"\"\"
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) {
a: author
t: title
}
}
\"\"\"
"""
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) -> 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)
%{
doc
| definitions:
for definition <- doc.definitions do
%{definition | operation: type}
end
}
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)
%{
doc
| definitions:
for definition <- doc.definitions do
%{definition | name: name}
end
}
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($age: Integer! = 42, $name: String! = "Joe", $key: Integer!, $id: ID) {
set_key(key: $key, value: "hello")
add_user(id: $id, name: $name, age: $age)
}
\"\"\"
"""
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: [
%VariableDefinition{
variable: %Variable{name: to_string(name)},
type: if(optional, do: type, else: %NonNullType{type: type}),
default_value: default
}
| definition.variable_definitions
],
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)
%{
doc
| definitions:
for definition <- doc.definitions do
%{
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 {
id
__typename
}
"
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()
...> |> fragment(:UserFields, :User)
...> |> field(:name, path: [:UserFields])
...> |> field(:email, path: [:UserFields])
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
}
fragment UserFields on User {
email
name
}
\"\"\"
"""
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, [])
field = %Field{name: name, alias: alias && to_string(alias), arguments: arguments(args)}
# Check if the first path element is a fragment name
{target_filter, field_path} =
case path do
[first | rest] ->
first_str = to_string(first)
# Check if this matches a fragment definition
has_fragment =
Enum.any?(doc.definitions, fn
%Fragment{name: ^first_str} -> true
_ -> false
end)
if has_fragment do
# Target only this fragment, and don't navigate into it as a field
{Access.filter(&match?(%Fragment{name: ^first_str}, &1)), rest}
else
# Target only operation definitions, use full path
{Access.filter(&match?(%OperationDefinition{}, &1)), path}
end
[] ->
# No path, target operation definitions
{Access.filter(&match?(%OperationDefinition{}, &1)), []}
end
optic =
[
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()
update_in(doc, optic, fn selection_list -> [field | selection_list || []] end)
end
# Helper to build navigation for a single path element
defp build_path_navigation({nil, opts}) when is_list(opts) do
# This is for matching inline fragments by type
type = Keyword.get(opts, :type)
if type do
type_str = to_string(type)
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], []),
Access.filter(fn
%InlineFragment{type_condition: %NamedType{name: ^type_str}} -> true
_ -> false
end)
]
else
# Match inline fragments without type condition
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], []),
Access.filter(&match?(%InlineFragment{type_condition: nil}, &1))
]
end
end
defp build_path_navigation({name, opts}) when is_list(opts) do
{name, alias, args} =
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
field = %Field{name: name, alias: alias && to_string(alias), arguments: arguments(args)}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(fn
%Field{} = f -> f.alias == name || f.name == name
_ -> false
end)
]
end
defp build_path_navigation(name) do
name = to_string(name)
field = %Field{name: name, arguments: []}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(fn
%Field{} = f -> f.alias == name || f.name == name
_ -> false
end)
]
end
@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 =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, alias, args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: alias && to_string(alias),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
Access.key(:arguments, [])
]
|> List.flatten()
update_in(doc, optic, fn argument_list -> [argument | argument_list || []] 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, [])
# Build optic to navigate to the selections containing the field
optic =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, field_alias, field_args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: field_alias && to_string(field_alias),
arguments: arguments(field_args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
access_key(:selection_set, nil, %SelectionSet{}),
Access.key(:selections, [])
]
|> List.flatten()
# Update the field in place, preserving its selection_set
update_in(doc, optic, fn selections ->
Enum.map(selections || [], fn
%Field{} = field when field.name == name or field.alias == name ->
# Update the field while preserving its selection_set
%{field |
alias: alias_name && to_string(alias_name),
arguments: arguments(args)
}
other ->
other
end)
end)
end
@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 =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, alias, args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: alias && to_string(alias),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
access_key(:selection_set, nil, %SelectionSet{}),
Access.key(:selections, [])
]
|> List.flatten()
update_in(doc, optic, fn selections ->
Enum.reject(selections || [], &(&1.alias == name || &1.name == 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(published: false, limit: 20) {
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 =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, alias, args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: alias && to_string(alias),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
Access.key(:arguments, [])
]
|> List.flatten()
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 at the specified path.
Directives are annotations that can be attached to fields to provide additional
metadata or modify execution behavior. Common directives include @include, @skip,
and @deprecated.
## Examples
Adding an @include directive with a condition:
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)
}
optic =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, alias, args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: alias && to_string(alias),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
Access.key(:directives, [])
]
|> List.flatten()
update_in(doc, optic, fn directive_list -> [directive | directive_list || []] end)
end
@doc """
Defines a reusable named fragment on a specific GraphQL type.
Fragments allow you to define reusable sets of fields that can be spread across
multiple queries. This function creates an empty fragment definition that can later
be populated with fields.
## Examples
Creating a basic fragment on a User type:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(:UserFields, :User)
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
}
fragment UserFields on User {
}
\"\"\"
Creating multiple fragments:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(:BasicUser, :User)
...> |> #{@gql}.fragment(:PostInfo, :Post)
...> |> to_string() |> String.replace("\\n\\n", "\\n")
\"\"\"
query {
}
fragment BasicUser on User {
}
fragment PostInfo on Post {
}
\"\"\"
"""
def fragment(doc, name, type) do
doc = parse(doc)
fragment = %Fragment{
name: to_string(name),
type_condition: %NamedType{name: to_string(type)},
directives: [],
selection_set: %SelectionSet{selections: []},
loc: %{line: nil}
}
%{doc | definitions: doc.definitions ++ [fragment]}
end
@doc """
Deletes a fragment from the document by its name.
## Examples
Removing a fragment:
iex> "query { user { id } }"
...> |> #{@gql}.fragment(:UserFields, :User)
...> |> #{@gql}.fragment(:PostInfo, :Post)
...> |> #{@gql}.remove_fragment(:UserFields)
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
user {
id
}
}
fragment PostInfo on Post {
}
\"\"\"
Removing a non-existent fragment does nothing:
iex> "query { user { id } }"
...> |> #{@gql}.fragment(:UserFields, :User)
...> |> #{@gql}.remove_fragment(:NonExistent)
...> |> to_string() |> String.replace(~r/\\n[ ]*\\n/m, "\\n")
\"\"\"
query {
user {
id
}
}
fragment UserFields on User {
}
\"\"\"
"""
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(:UserFields, :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 {
email
name
}
\"\"\"
Spreading a fragment in a nested field:
iex> #{@gql}.new()
...> |> #{@gql}.fragment(:ContactInfo, :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 {
phone
email
}
\"\"\"
"""
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: [],
loc: %{line: nil}
}
# Check if the first path element is a fragment name
{target_filter, field_path} =
case path do
[first | rest] ->
first_str = to_string(first)
# Check if this matches a fragment definition
has_fragment =
Enum.any?(doc.definitions, fn
%Fragment{name: ^first_str} -> true
_ -> false
end)
if has_fragment do
# Target only this fragment, and don't navigate into it as a field
{Access.filter(&match?(%Fragment{name: ^first_str}, &1)), rest}
else
# Target only operation definitions, use full path
{Access.filter(&match?(%OperationDefinition{}, &1)), path}
end
[] ->
# No path, target operation definitions
{Access.filter(&match?(%OperationDefinition{}, &1)), []}
end
optic =
[
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()
update_in(doc, optic, fn selection_list -> [fragment_spread | selection_list || []] end)
end
@doc """
Adds an inline fragment for handling union or interface types at a specific path.
Inline fragments allow you to conditionally include fields based on the concrete type
of a union or interface. Unlike named fragments, inline fragments are defined directly
in the query without a separate fragment definition.
## Examples
Adding an inline fragment to handle a union type:
iex> #{@gql}.new()
...> |> #{@gql}.field(:search, args: %{term: "elixir"})
...> |> #{@gql}.inline_fragment(:User, path: [:search])
...> |> #{@gql}.field(:name, path: [:search, {nil, type: :User}])
...> |> #{@gql}.field(:email, path: [:search, {nil, type: :User}])
...> |> #{@gql}.inline_fragment(: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 Post {
content
title
}
... on User {
email
name
}
}
}
\"\"\"
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}.inline_fragment(nil, path: [:node])
...> |> #{@gql}.field(:__typename, path: [:node, {nil, type: nil}])
...> |> to_string()
\"\"\"
query {
node(id: "123") {
... {
__typename
}
id
}
}
\"\"\"
"""
def inline_fragment(doc, type, opts \\ []) do
doc = parse(doc)
path = Keyword.get(opts, :path, []) |> List.wrap()
type_condition =
if type do
%NamedType{name: to_string(type)}
else
nil
end
inline_fragment = %InlineFragment{
type_condition: type_condition,
directives: [],
selection_set: %SelectionSet{selections: []},
loc: %{line: nil}
}
optic =
[
access_key(:definitions, nil, []),
Access.all(),
for field_name <- path do
{field_name, alias, args} =
case field_name do
{name, opts} ->
{to_string(name), Keyword.get(opts, :alias), Keyword.get(opts, :args, [])}
_ ->
{to_string(field_name), nil, []}
end
field = %Field{
name: field_name,
alias: alias && to_string(alias),
arguments: arguments(args)
}
[
access_key(:selection_set, nil, %SelectionSet{}),
access_key(:selections, [], [field]),
Access.filter(&(&1.alias == field_name || &1.name == field_name))
]
end,
access_key(:selection_set, nil, %SelectionSet{}),
Access.key(:selections, [])
]
|> List.flatten()
update_in(doc, optic, fn selection_list -> [inline_fragment | selection_list || []] end)
end
@doc """
Inlines all fragment spreads into the main selection set for simplified document structure.
"""
def inline_fragments(doc) do
# let's call spread_fragment for all fragments
doc
end
@doc """
Automatically injects the __typename field into all object selections.
iex> \"""
...> query {
...> apple {
...> foo
...> bar {
...> baz
...> }
...> }
...> }
...> \"""
...> |> #{@gql}.inject_typenames()
...> |> to_string()
\"\"\"
query {
__typename
apple {
__typename
foo
bar {
__typename
baz
}
}
}
\"\"\"
"""
def inject_typenames(doc) do
for path <- 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_valriables(%{id: 42})
...> |> to_string()
\"\"\"
query Q {
get(id: 42) {
name
}
}
\"\"\"
"""
def inline_valriables(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
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} ->
field_identifier = field.alias || field.name
args_signature =
field.arguments
|> Enum.map(fn arg -> {arg.name, inspect(arg.value)} end)
|> Enum.sort()
key = {field_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
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), seen}
end
end)
result
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(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)
%{
doc
| definitions:
for definition <- doc.definitions do
%{
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
defp paths(doc) do
doc = parse(doc)
doc.definitions
|> Enum.flat_map(fn definition ->
_paths(definition.selection_set)
end)
|> Enum.uniq()
end
defp _paths(nil), do: []
defp _paths(%SelectionSet{selections: selections}) do
nested_paths =
for field <- selections, field.selection_set != nil do
field_name = field.alias || field.name
for path <- _paths(field.selection_set) do
[field_name | path]
end
end
|> flatten_just_one_level()
[[] | nested_paths]
end
defp flatten_just_one_level(list), do: Enum.flat_map(list, &Function.identity/1)
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