Skip to content

Commit 01e8e4b

Browse files
authored
feat(spec): Add support for @oneOf directive (#1386)
* feat(spec): Add support for 'oneOf' directive * Implement schema compilation phase * Move module doc to comment * Add document phase and test * Refactor to not require get_in/1
1 parent 4230cc4 commit 01e8e4b

File tree

8 files changed

+280
-0
lines changed

8 files changed

+280
-0
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
defmodule Absinthe.Phase.Document.Validation.OneOfDirective do
2+
@moduledoc false
3+
4+
# Document validation phase that ensures that only one value is provided for input types
5+
# that have the oneOf directive set
6+
7+
use Absinthe.Phase
8+
9+
alias Absinthe.Blueprint
10+
alias Absinthe.Blueprint.Input.Argument
11+
alias Absinthe.Blueprint.Input.Object
12+
alias Absinthe.Phase.Error
13+
14+
def run(blueprint, _options \\ []) do
15+
{:ok, Blueprint.prewalk(blueprint, &process/1)}
16+
end
17+
18+
# Ignore input objects without schema nodes
19+
defp process(%Argument{input_value: %{normalized: %Object{schema_node: nil}}} = node), do: node
20+
21+
defp process(%Argument{input_value: %{normalized: %Object{} = object}} = node) do
22+
if Keyword.has_key?(object.schema_node.__private__, :one_of) and
23+
field_count(object.fields) > 1 do
24+
message =
25+
~s[The Input Type "#{object.schema_node.name}" has the @oneOf directive. It must have exactly one non-null field.]
26+
27+
error = %Error{locations: [node.source_location], message: message, phase: __MODULE__}
28+
put_error(node, error)
29+
else
30+
node
31+
end
32+
end
33+
34+
defp process(node), do: node
35+
36+
defp field_count(fields) do
37+
fields
38+
|> Enum.map(fn field ->
39+
get_in(field, Enum.map(~w[input_value normalized __struct__]a, &Access.key/1))
40+
end)
41+
|> Enum.count(&(&1 != Absinthe.Blueprint.Input.Null))
42+
end
43+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Absinthe.Phase.Schema.Validation.OneOfDirective do
2+
@moduledoc false
3+
4+
# Schema validation phase that ensures that uses of the `@oneOf` directive comply with the spec
5+
6+
use Absinthe.Phase
7+
8+
alias Absinthe.Blueprint
9+
alias Absinthe.Blueprint.Schema.InputObjectTypeDefinition
10+
alias Absinthe.Blueprint.TypeReference.NonNull
11+
12+
def run(blueprint, _options \\ []) do
13+
{:ok, Blueprint.prewalk(blueprint, &process/1)}
14+
end
15+
16+
defp process(%InputObjectTypeDefinition{directives: [_ | _] = directives} = node) do
17+
one_of? = Enum.any?(directives, &(&1.name == "one_of"))
18+
19+
cond do
20+
one_of? and length(node.fields) == 1 ->
21+
add_error(node, """
22+
The oneOf directive is only valid on input types with more then one field.
23+
The input type "#{node.name}" only defines one field.
24+
""")
25+
26+
one_of? and Enum.any?(node.fields, &match?(%NonNull{}, &1.type)) ->
27+
add_error(node, """
28+
The oneOf directive is only valid on input types with all nullable fields.
29+
The input type "#{node.name}" has one or more nullable fields.
30+
""")
31+
32+
true ->
33+
node
34+
end
35+
end
36+
37+
defp process(node), do: node
38+
39+
defp add_error(node, message) do
40+
error = %Absinthe.Phase.Error{
41+
locations: [node.__reference__.location],
42+
message: message,
43+
phase: __MODULE__
44+
}
45+
46+
put_error(node, error)
47+
end
48+
end

lib/absinthe/pipeline.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ defmodule Absinthe.Pipeline do
103103
Phase.Document.Validation.UniqueArgumentNames,
104104
Phase.Document.Validation.UniqueInputFieldNames,
105105
Phase.Document.Validation.FieldsOnCorrectType,
106+
Phase.Document.Validation.OneOfDirective,
106107
Phase.Document.Validation.OnlyOneSubscription,
107108
# Check Validation
108109
{Phase.Document.Validation.Result, options},
@@ -187,6 +188,7 @@ defmodule Absinthe.Pipeline do
187188
Phase.Schema.Validation.QueryTypeMustBeObject,
188189
Phase.Schema.Validation.NamesMustBeValid,
189190
Phase.Schema.Validation.UniqueFieldNames,
191+
Phase.Schema.Validation.OneOfDirective,
190192
Phase.Schema.RegisterTriggers,
191193
Phase.Schema.MarkReferenced,
192194
Phase.Schema.ReformatDescriptions,

lib/absinthe/schema/prototype/notation.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ defmodule Absinthe.Schema.Prototype.Notation do
4545
on [:scalar]
4646
end
4747

48+
# https://spec.graphql.org/September2025/#sec--oneOf
49+
directive :one_of do
50+
description """
51+
The @oneOf built-in directive is used within the type system definition language to indicate an Input Object is a OneOf Input Object.
52+
53+
A OneOf Input Object is a special variant of Input Object where exactly one field must be set and non-null, all others being omitted.
54+
This is useful for representing situations where an input may be one of many different options.
55+
"""
56+
57+
repeatable false
58+
on [:input_object]
59+
60+
expand(fn _args, node ->
61+
%{node | __private__: Keyword.put(node.__private__, :one_of, true)}
62+
end)
63+
end
64+
4865
def pipeline(pipeline) do
4966
pipeline
5067
|> Absinthe.Pipeline.without(Absinthe.Phase.Schema.Validation.QueryTypeMustBeObject)

test/absinthe/integration/execution/introspection/directives_test.exs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ defmodule Elixir.Absinthe.Integration.Execution.Introspection.DirectivesTest do
5656
"onOperation" => false,
5757
"isRepeatable" => false
5858
},
59+
%{
60+
"args" => [],
61+
"isRepeatable" => false,
62+
"locations" => ["INPUT_OBJECT"],
63+
"name" => "oneOf",
64+
"onField" => false,
65+
"onFragment" => false,
66+
"onOperation" => false
67+
},
5968
%{
6069
"args" => [
6170
%{

test/absinthe/introspection_test.exs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ defmodule Absinthe.IntrospectionTest do
6262
"onFragment" => true,
6363
"onOperation" => false
6464
},
65+
%{
66+
"description" =>
67+
"The @oneOf built-in directive is used within the type system definition language to indicate an Input Object is a OneOf Input Object.\n\nA OneOf Input Object is a special variant of Input Object where exactly one field must be set and non-null, all others being omitted.\nThis is useful for representing situations where an input may be one of many different options.",
68+
"isRepeatable" => false,
69+
"locations" => ["INPUT_OBJECT"],
70+
"name" => "oneOf",
71+
"onField" => false,
72+
"onFragment" => false,
73+
"onOperation" => false
74+
},
6575
%{
6676
"description" =>
6777
"Directs the executor to skip this field or fragment when the `if` argument is true.",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
defmodule Absinthe.Phase.Document.Validation.OneOfDirectiveTest do
2+
use Absinthe.Case, async: true
3+
4+
defmodule Schema do
5+
use Absinthe.Schema
6+
7+
input_object :valid_input do
8+
directive :one_of
9+
field :id, :id
10+
field :name, :string
11+
end
12+
13+
query do
14+
field :valid, :boolean do
15+
arg :input, :valid_input
16+
resolve fn _, _ -> {:ok, true} end
17+
end
18+
end
19+
end
20+
21+
@query "query NamedQuery($input: ValidInput!) { valid(input: $input) }"
22+
@message ~s[The Input Type "ValidInput" has the @oneOf directive. It must have exactly one non-null field.]
23+
24+
describe "run/2" do
25+
test "without arg" do
26+
assert {:ok, %{data: _} = result} = Absinthe.run("query { valid }", Schema)
27+
refute result[:errors]
28+
end
29+
30+
test "with one inline arg" do
31+
assert {:ok, %{data: _} = result} = Absinthe.run("query { valid(input: {id: 1}) }", Schema)
32+
refute result[:errors]
33+
end
34+
35+
test "with both inline args but one is null" do
36+
query = "query { valid(input: {id: 1, name: null}) }"
37+
assert {:ok, %{data: _} = result} = Absinthe.run(query, Schema)
38+
refute result[:errors]
39+
end
40+
41+
test "with one variable arg" do
42+
options = [variables: %{"input" => %{"id" => 1}}]
43+
assert {:ok, %{data: _} = result} = Absinthe.run(@query, Schema, options)
44+
refute result[:errors]
45+
end
46+
47+
test "with both variable args but one is null" do
48+
options = [variables: %{"input" => %{"id" => 1, "name" => nil}}]
49+
assert {:ok, %{data: _} = result} = Absinthe.run(@query, Schema, options)
50+
refute result[:errors]
51+
end
52+
53+
test "with both inline args" do
54+
query = ~s[query { valid(input: {id: 1, name: "a"}) }]
55+
assert {:ok, %{errors: [error]} = result} = Absinthe.run(query, Schema)
56+
assert %{locations: [%{column: 15, line: 1}], message: @message} = error
57+
refute result[:data]
58+
end
59+
60+
test "with both variable args" do
61+
options = [variables: %{"input" => %{"id" => 1, "name" => "a"}}]
62+
assert {:ok, %{errors: [error]} = result} = Absinthe.run(@query, Schema, options)
63+
assert %{locations: [%{column: 47, line: 1}], message: @message} = error
64+
refute result[:data]
65+
end
66+
end
67+
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule Absinthe.Phase.Schema.Validation.OneOfDirectiveTest do
2+
use Absinthe.Case, async: true
3+
4+
alias Absinthe.Phase.Schema.Validation.OneOfDirective
5+
alias Absinthe.Pipeline
6+
7+
defmodule Modifier do
8+
def pipeline(pipeline), do: Pipeline.upto(pipeline, OneOfDirective)
9+
end
10+
11+
defmodule Schema do
12+
use Absinthe.Schema
13+
14+
@pipeline_modifier Modifier
15+
16+
input_object :no_directive_input do
17+
field :id, non_null(:id)
18+
end
19+
20+
input_object :valid_input do
21+
directive :one_of
22+
field :id, :id
23+
field :name, :string
24+
end
25+
26+
input_object :single_field_input do
27+
directive :one_of
28+
field :id, :id
29+
end
30+
31+
input_object :non_null_field_input do
32+
directive :one_of
33+
field :id, non_null(:id)
34+
field :name, :string
35+
end
36+
37+
query do
38+
field :valid, :boolean do
39+
arg :no_directive, :no_directive_input
40+
arg :valid, :valid_input
41+
arg :single_field, :single_field_input
42+
arg :non_null_field, :non_null_field_input
43+
end
44+
end
45+
end
46+
47+
setup_all do
48+
{:ok, blueprint} = OneOfDirective.run(Schema.__absinthe_blueprint__())
49+
[blueprint: blueprint]
50+
end
51+
52+
describe "run/2" do
53+
test "field without directive is a noop", %{blueprint: blueprint} do
54+
assert %{errors: []} = find_definition(blueprint, :no_directive_input)
55+
end
56+
57+
test "valid use is a noop", %{blueprint: blueprint} do
58+
assert %{errors: []} = find_definition(blueprint, :valid_input)
59+
end
60+
61+
test "on an object with a single field adds an error", %{blueprint: blueprint} do
62+
assert %{errors: [error]} = find_definition(blueprint, :single_field_input)
63+
assert %Absinthe.Phase.Error{message: message, phase: OneOfDirective} = error
64+
65+
assert message =~
66+
"The oneOf directive is only valid on input types with more then one field."
67+
end
68+
69+
test "on an object with a non_null field adds an error", %{blueprint: blueprint} do
70+
assert %{errors: [error]} = find_definition(blueprint, :non_null_field_input)
71+
assert %Absinthe.Phase.Error{message: message, phase: OneOfDirective} = error
72+
73+
assert message =~
74+
"The oneOf directive is only valid on input types with all nullable fields."
75+
end
76+
end
77+
78+
defp find_definition(blueprint, identifier) do
79+
blueprint.schema_definitions
80+
|> Enum.flat_map(& &1.type_definitions)
81+
|> Enum.filter(&is_struct/1)
82+
|> Enum.find(&(&1.identifier == identifier))
83+
end
84+
end

0 commit comments

Comments
 (0)