Skip to content

Commit 8154766

Browse files
authored
improvement: validate cross-field dependencies in graphql DSL options at compile time (#416)
1 parent 55df7bc commit 8154766

File tree

5 files changed

+752
-1
lines changed

5 files changed

+752
-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: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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(
62+
dsl,
63+
resource,
64+
included_rels,
65+
:paginate_relationship_with
66+
)
67+
|> check_excluded_relationship(dsl, resource, included_rels, :filterable_fields)
68+
|> check_excluded_relationship(dsl, resource, included_rels, :field_names)
69+
|> check_excluded_relationship(dsl, resource, included_rels, :nullable_fields)
70+
else
71+
warnings
72+
end
73+
end)
74+
75+
case warnings do
76+
[] -> :ok
77+
list -> {:warn, list}
78+
end
79+
end
80+
81+
defp validate_show_hide_contradiction!(_resource, show_fields, hide_fields)
82+
when is_nil(show_fields) or is_nil(hide_fields),
83+
do: :ok
84+
85+
defp validate_show_hide_contradiction!(resource, show_fields, hide_fields) do
86+
overlap =
87+
MapSet.intersection(MapSet.new(show_fields), MapSet.new(hide_fields))
88+
|> MapSet.to_list()
89+
|> Enum.sort()
90+
91+
unless Enum.empty?(overlap) do
92+
raise Spark.Error.DslError,
93+
module: resource,
94+
path: [:graphql],
95+
message: """
96+
Fields cannot appear in both `show_fields` and `hide_fields`.
97+
98+
Conflicting fields: #{inspect(overlap)}
99+
"""
100+
end
101+
end
102+
103+
defp compute_visible_fields(dsl, show_fields, hide_fields) do
104+
base =
105+
if show_fields do
106+
MapSet.new(show_fields)
107+
else
108+
dsl |> Ash.Resource.Info.public_fields() |> MapSet.new(& &1.name)
109+
end
110+
111+
hidden = MapSet.new(hide_fields || [])
112+
MapSet.difference(base, hidden)
113+
end
114+
115+
defp check_invisible_fields(warnings, dsl, resource, visible, option, option_name) do
116+
value = get_option(dsl, option)
117+
118+
if is_list(value) do
119+
field_names = extract_field_names(value)
120+
121+
Enum.reduce(field_names, warnings, fn field, acc ->
122+
if MapSet.member?(visible, field) do
123+
acc
124+
else
125+
[invisible_field_warning(resource, field, option_name) | acc]
126+
end
127+
end)
128+
else
129+
warnings
130+
end
131+
end
132+
133+
defp check_invisible_field_names(warnings, dsl, resource, visible) do
134+
field_names = AshGraphql.Resource.Info.field_names(dsl)
135+
136+
if is_list(field_names) and field_names != [] do
137+
Enum.reduce(field_names, warnings, fn {field, _renamed}, acc ->
138+
if MapSet.member?(visible, field) do
139+
acc
140+
else
141+
[invisible_field_warning(resource, field, "field_names") | acc]
142+
end
143+
end)
144+
else
145+
warnings
146+
end
147+
end
148+
149+
defp check_invisible_relationships(warnings, dsl, resource, visible) do
150+
# relationships option defaults to nil (meaning all), only check when explicitly set
151+
relationships = Spark.Dsl.Extension.get_opt(dsl, [:graphql], :relationships, nil)
152+
153+
if is_list(relationships) do
154+
Enum.reduce(relationships, warnings, fn rel, acc ->
155+
if MapSet.member?(visible, rel) do
156+
acc
157+
else
158+
[invisible_field_warning(resource, rel, "relationships") | acc]
159+
end
160+
end)
161+
else
162+
warnings
163+
end
164+
end
165+
166+
defp check_invisible_paginate_relationship_with(warnings, dsl, resource, visible) do
167+
paginate_with = AshGraphql.Resource.Info.paginate_relationship_with(dsl)
168+
169+
if is_list(paginate_with) and paginate_with != [] do
170+
Enum.reduce(paginate_with, warnings, fn {rel, _strategy}, acc ->
171+
if MapSet.member?(visible, rel) do
172+
acc
173+
else
174+
[invisible_field_warning(resource, rel, "paginate_relationship_with") | acc]
175+
end
176+
end)
177+
else
178+
warnings
179+
end
180+
end
181+
182+
defp check_invisible_attribute_input_types(warnings, dsl, resource, visible) do
183+
input_types = AshGraphql.Resource.Info.attribute_input_types(dsl)
184+
185+
if is_list(input_types) and input_types != [] do
186+
# Only check keys that are actual public attributes. Non-attribute keys
187+
# (e.g. relationships) are caught by VerifyFieldReferences as invalid.
188+
attribute_names =
189+
dsl |> Ash.Resource.Info.public_attributes() |> MapSet.new(& &1.name)
190+
191+
Enum.reduce(input_types, warnings, fn {attr, _type}, acc ->
192+
if not MapSet.member?(attribute_names, attr) or MapSet.member?(visible, attr) do
193+
acc
194+
else
195+
[invisible_field_warning(resource, attr, "attribute_input_types") | acc]
196+
end
197+
end)
198+
else
199+
warnings
200+
end
201+
end
202+
203+
defp check_excluded_relationship(
204+
warnings,
205+
dsl,
206+
resource,
207+
included_rels,
208+
:paginate_relationship_with
209+
) do
210+
paginate_with = AshGraphql.Resource.Info.paginate_relationship_with(dsl)
211+
212+
if is_list(paginate_with) and paginate_with != [] do
213+
Enum.reduce(paginate_with, warnings, fn {rel, _strategy}, acc ->
214+
if MapSet.member?(included_rels, rel) do
215+
acc
216+
else
217+
[excluded_relationship_warning(resource, rel, "paginate_relationship_with") | acc]
218+
end
219+
end)
220+
else
221+
warnings
222+
end
223+
end
224+
225+
defp check_excluded_relationship(warnings, dsl, resource, included_rels, option) do
226+
relationship_names =
227+
dsl
228+
|> Ash.Resource.Info.public_relationships()
229+
|> MapSet.new(& &1.name)
230+
231+
values = get_relationship_option(dsl, option)
232+
233+
if is_list(values) and values != [] do
234+
values
235+
|> extract_field_names()
236+
|> Enum.filter(&MapSet.member?(relationship_names, &1))
237+
|> Enum.reduce(warnings, fn rel, acc ->
238+
if MapSet.member?(included_rels, rel) do
239+
acc
240+
else
241+
[excluded_relationship_warning(resource, rel, to_string(option)) | acc]
242+
end
243+
end)
244+
else
245+
warnings
246+
end
247+
end
248+
249+
defp excluded_relationship_warning(resource, field, option_name) do
250+
"Relationship `#{inspect(field)}` in `#{option_name}` is not included in `relationships` " <>
251+
"and will have no effect in #{inspect(resource)}."
252+
end
253+
254+
defp get_relationship_option(dsl, :filterable_fields),
255+
do: AshGraphql.Resource.Info.filterable_fields(dsl)
256+
257+
defp get_relationship_option(dsl, :field_names),
258+
do: AshGraphql.Resource.Info.field_names(dsl)
259+
260+
defp get_relationship_option(dsl, :nullable_fields),
261+
do: AshGraphql.Resource.Info.nullable_fields(dsl)
262+
263+
defp extract_field_names(fields) do
264+
Enum.map(fields, fn
265+
field when is_atom(field) -> field
266+
{field, _value} when is_atom(field) -> field
267+
end)
268+
end
269+
270+
defp invisible_field_warning(resource, field, option_name) do
271+
"Field `#{inspect(field)}` in `#{option_name}` is not visible " <>
272+
"(it is hidden or not in show_fields) and will have no effect in #{inspect(resource)}."
273+
end
274+
275+
defp get_option(dsl, :sortable_fields), do: AshGraphql.Resource.Info.sortable_fields(dsl)
276+
defp get_option(dsl, :filterable_fields), do: AshGraphql.Resource.Info.filterable_fields(dsl)
277+
defp get_option(dsl, :nullable_fields), do: AshGraphql.Resource.Info.nullable_fields(dsl)
278+
end

lib/resource/verifiers/verify_field_references.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ defmodule AshGraphql.Resource.Verifiers.VerifyFieldReferences do
4545
)
4646

4747
validate_option(dsl, resource, :relationships, relationship_names, "relationship")
48+
validate_option(dsl, resource, :attribute_input_types, attribute_names, "attribute")
4849

4950
:ok
5051
end
@@ -88,6 +89,9 @@ defmodule AshGraphql.Resource.Verifiers.VerifyFieldReferences do
8889
defp get_option(dsl, :sortable_fields), do: AshGraphql.Resource.Info.sortable_fields(dsl)
8990
defp get_option(dsl, :filterable_fields), do: AshGraphql.Resource.Info.filterable_fields(dsl)
9091

92+
defp get_option(dsl, :attribute_input_types),
93+
do: AshGraphql.Resource.Info.attribute_input_types(dsl)
94+
9195
# relationships/1 in Info falls back to all public relationships when nil,
9296
# so we read the raw option to only validate when explicitly set by the user.
9397
defp get_option(dsl, :relationships) do

0 commit comments

Comments
 (0)