Skip to content

Commit 20fafdb

Browse files
committed
improvement: validate cross-field dependencies in graphql DSL options at compile time
1 parent 55df7bc commit 20fafdb

File tree

3 files changed

+709
-1
lines changed

3 files changed

+709
-1
lines changed

lib/resource/resource.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,8 @@ defmodule AshGraphql.Resource do
508508
AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith,
509509
AshGraphql.Resource.Verifiers.VerifyArgumentInputTypes,
510510
AshGraphql.Resource.Verifiers.VerifyFieldReferences,
511-
AshGraphql.Resource.Verifiers.VerifyFilterableFields
511+
AshGraphql.Resource.Verifiers.VerifyFilterableFields,
512+
AshGraphql.Resource.Verifiers.VerifyFieldDependencies
512513
]
513514

514515
@sections [@graphql]
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# SPDX-FileCopyrightText: 2020 ash_graphql contributors <https://github.com/ash-project/ash_graphql/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshGraphql.Resource.Verifiers.VerifyFieldDependencies do
6+
# Validates cross-field dependencies between graphql DSL options.
7+
# For example, a field in sortable_fields that is hidden via hide_fields
8+
# will have no effect at runtime - this verifier warns about such cases.
9+
@moduledoc false
10+
use Spark.Dsl.Verifier
11+
12+
alias Spark.Dsl.Transformer
13+
14+
@impl true
15+
def verify(dsl) do
16+
resource = Transformer.get_persisted(dsl, :module)
17+
show_fields = AshGraphql.Resource.Info.show_fields(dsl)
18+
hide_fields = AshGraphql.Resource.Info.hide_fields(dsl)
19+
20+
# Hard error: show_fields and hide_fields must not overlap
21+
validate_show_hide_contradiction!(resource, show_fields, hide_fields)
22+
23+
has_visibility_constraints = not (is_nil(show_fields) and is_nil(hide_fields))
24+
explicit_relationships = Spark.Dsl.Extension.get_opt(dsl, [:graphql], :relationships, nil)
25+
26+
warnings =
27+
[]
28+
|> then(fn warnings ->
29+
if has_visibility_constraints do
30+
visible = compute_visible_fields(dsl, show_fields, hide_fields)
31+
32+
warnings
33+
|> check_invisible_fields(dsl, resource, visible, :sortable_fields, "sortable_fields")
34+
|> check_invisible_fields(
35+
dsl,
36+
resource,
37+
visible,
38+
:filterable_fields,
39+
"filterable_fields"
40+
)
41+
|> check_invisible_fields(
42+
dsl,
43+
resource,
44+
visible,
45+
:nullable_fields,
46+
"nullable_fields"
47+
)
48+
|> check_invisible_field_names(dsl, resource, visible)
49+
|> check_invisible_relationships(dsl, resource, visible)
50+
|> check_invisible_paginate_relationship_with(dsl, resource, visible)
51+
|> check_invisible_attribute_input_types(dsl, resource, visible)
52+
else
53+
warnings
54+
end
55+
end)
56+
|> then(fn warnings ->
57+
if is_list(explicit_relationships) do
58+
included_rels = MapSet.new(explicit_relationships)
59+
60+
warnings
61+
|> check_excluded_relationship(dsl, resource, included_rels, :paginate_relationship_with)
62+
|> check_excluded_relationship(dsl, resource, included_rels, :filterable_fields)
63+
|> check_excluded_relationship(dsl, resource, included_rels, :field_names)
64+
|> check_excluded_relationship(dsl, resource, included_rels, :nullable_fields)
65+
else
66+
warnings
67+
end
68+
end)
69+
70+
case warnings do
71+
[] -> :ok
72+
list -> {:warn, list}
73+
end
74+
end
75+
76+
defp validate_show_hide_contradiction!(_resource, show_fields, hide_fields)
77+
when is_nil(show_fields) or is_nil(hide_fields),
78+
do: :ok
79+
80+
defp validate_show_hide_contradiction!(resource, show_fields, hide_fields) do
81+
overlap =
82+
MapSet.intersection(MapSet.new(show_fields), MapSet.new(hide_fields))
83+
|> MapSet.to_list()
84+
|> Enum.sort()
85+
86+
unless Enum.empty?(overlap) do
87+
raise Spark.Error.DslError,
88+
module: resource,
89+
path: [:graphql],
90+
message: """
91+
Fields cannot appear in both `show_fields` and `hide_fields`.
92+
93+
Conflicting fields: #{inspect(overlap)}
94+
"""
95+
end
96+
end
97+
98+
defp compute_visible_fields(dsl, show_fields, hide_fields) do
99+
base =
100+
if show_fields do
101+
MapSet.new(show_fields)
102+
else
103+
dsl |> Ash.Resource.Info.public_fields() |> MapSet.new(& &1.name)
104+
end
105+
106+
hidden = MapSet.new(hide_fields || [])
107+
MapSet.difference(base, hidden)
108+
end
109+
110+
defp check_invisible_fields(warnings, dsl, resource, visible, option, option_name) do
111+
value = get_option(dsl, option)
112+
113+
if is_list(value) do
114+
field_names = extract_field_names(value)
115+
116+
Enum.reduce(field_names, warnings, fn field, acc ->
117+
if MapSet.member?(visible, field) do
118+
acc
119+
else
120+
[invisible_field_warning(resource, field, option_name) | acc]
121+
end
122+
end)
123+
else
124+
warnings
125+
end
126+
end
127+
128+
defp check_invisible_field_names(warnings, dsl, resource, visible) do
129+
field_names = AshGraphql.Resource.Info.field_names(dsl)
130+
131+
if is_list(field_names) and field_names != [] do
132+
Enum.reduce(field_names, warnings, fn {field, _renamed}, acc ->
133+
if MapSet.member?(visible, field) do
134+
acc
135+
else
136+
[invisible_field_warning(resource, field, "field_names") | acc]
137+
end
138+
end)
139+
else
140+
warnings
141+
end
142+
end
143+
144+
defp check_invisible_relationships(warnings, dsl, resource, visible) do
145+
# relationships option defaults to nil (meaning all), only check when explicitly set
146+
relationships = Spark.Dsl.Extension.get_opt(dsl, [:graphql], :relationships, nil)
147+
148+
if is_list(relationships) do
149+
Enum.reduce(relationships, warnings, fn rel, acc ->
150+
if MapSet.member?(visible, rel) do
151+
acc
152+
else
153+
[invisible_field_warning(resource, rel, "relationships") | acc]
154+
end
155+
end)
156+
else
157+
warnings
158+
end
159+
end
160+
161+
defp check_invisible_paginate_relationship_with(warnings, dsl, resource, visible) do
162+
paginate_with = AshGraphql.Resource.Info.paginate_relationship_with(dsl)
163+
164+
if is_list(paginate_with) and paginate_with != [] do
165+
Enum.reduce(paginate_with, warnings, fn {rel, _strategy}, acc ->
166+
if MapSet.member?(visible, rel) do
167+
acc
168+
else
169+
[invisible_field_warning(resource, rel, "paginate_relationship_with") | acc]
170+
end
171+
end)
172+
else
173+
warnings
174+
end
175+
end
176+
177+
defp check_invisible_attribute_input_types(warnings, dsl, resource, visible) do
178+
input_types = AshGraphql.Resource.Info.attribute_input_types(dsl)
179+
180+
if is_list(input_types) and input_types != [] do
181+
Enum.reduce(input_types, warnings, fn {attr, _type}, acc ->
182+
if MapSet.member?(visible, attr) do
183+
acc
184+
else
185+
[invisible_field_warning(resource, attr, "attribute_input_types") | acc]
186+
end
187+
end)
188+
else
189+
warnings
190+
end
191+
end
192+
193+
defp check_excluded_relationship(warnings, dsl, resource, included_rels, :paginate_relationship_with) do
194+
paginate_with = AshGraphql.Resource.Info.paginate_relationship_with(dsl)
195+
196+
if is_list(paginate_with) and paginate_with != [] do
197+
Enum.reduce(paginate_with, warnings, fn {rel, _strategy}, acc ->
198+
if MapSet.member?(included_rels, rel) do
199+
acc
200+
else
201+
[excluded_relationship_warning(resource, rel, "paginate_relationship_with") | acc]
202+
end
203+
end)
204+
else
205+
warnings
206+
end
207+
end
208+
209+
defp check_excluded_relationship(warnings, dsl, resource, included_rels, option) do
210+
relationship_names =
211+
dsl
212+
|> Ash.Resource.Info.public_relationships()
213+
|> MapSet.new(& &1.name)
214+
215+
values = get_relationship_option(dsl, option)
216+
217+
if is_list(values) and values != [] do
218+
values
219+
|> extract_field_names()
220+
|> Enum.filter(&MapSet.member?(relationship_names, &1))
221+
|> Enum.reduce(warnings, fn rel, acc ->
222+
if MapSet.member?(included_rels, rel) do
223+
acc
224+
else
225+
[excluded_relationship_warning(resource, rel, to_string(option)) | acc]
226+
end
227+
end)
228+
else
229+
warnings
230+
end
231+
end
232+
233+
defp excluded_relationship_warning(resource, field, option_name) do
234+
"Relationship `#{inspect(field)}` in `#{option_name}` is not included in `relationships` " <>
235+
"and will have no effect in #{inspect(resource)}."
236+
end
237+
238+
defp get_relationship_option(dsl, :filterable_fields),
239+
do: AshGraphql.Resource.Info.filterable_fields(dsl)
240+
241+
defp get_relationship_option(dsl, :field_names),
242+
do: AshGraphql.Resource.Info.field_names(dsl)
243+
244+
defp get_relationship_option(dsl, :nullable_fields),
245+
do: AshGraphql.Resource.Info.nullable_fields(dsl)
246+
247+
defp extract_field_names(fields) do
248+
Enum.map(fields, fn
249+
field when is_atom(field) -> field
250+
{field, _value} when is_atom(field) -> field
251+
end)
252+
end
253+
254+
defp invisible_field_warning(resource, field, option_name) do
255+
"Field `#{inspect(field)}` in `#{option_name}` is not visible " <>
256+
"(it is hidden or not in show_fields) and will have no effect in #{inspect(resource)}."
257+
end
258+
259+
defp get_option(dsl, :sortable_fields), do: AshGraphql.Resource.Info.sortable_fields(dsl)
260+
defp get_option(dsl, :filterable_fields), do: AshGraphql.Resource.Info.filterable_fields(dsl)
261+
defp get_option(dsl, :nullable_fields), do: AshGraphql.Resource.Info.nullable_fields(dsl)
262+
end

0 commit comments

Comments
 (0)