Skip to content

Commit 3035e4a

Browse files
committed
improvement: add singleton_entity_keys to sections
closes #245
1 parent 1f290d3 commit 3035e4a

File tree

5 files changed

+144
-2
lines changed

5 files changed

+144
-2
lines changed

lib/spark/dsl/extension.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,12 @@ defmodule Spark.Dsl.Extension do
439439
@doc false
440440
def sections, do: @_sections
441441
@doc false
442-
def verifiers, do: [Spark.Dsl.Verifiers.VerifyEntityUniqueness | @_verifiers]
442+
def verifiers,
443+
do: [
444+
Spark.Dsl.Verifiers.VerifyEntityUniqueness,
445+
Spark.Dsl.Verifiers.VerifySectionSingletonEntities | @_verifiers
446+
]
447+
443448
@doc false
444449
def persisters, do: @_persisters
445450
@doc false

lib/spark/dsl/section.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ defmodule Spark.Dsl.Section do
2525
To create a section that is available at the top level (i.e not nested inside of its own name), use
2626
`top_level?: true`. Remember, however, that this has no effect on sections nested inside of other sections.
2727
28+
`singleton_entity_keys` specifies a set of entity names (by their `:name` field) that should only
29+
appear at most once within the section. This is validated automatically during compilation.
30+
2831
For a full example, see `Spark.Dsl.Extension`.
2932
"""
3033
defstruct [
@@ -40,6 +43,7 @@ defmodule Spark.Dsl.Section do
4043
top_level?: false,
4144
no_depend_modules: [],
4245
auto_set_fields: [],
46+
singleton_entity_keys: [],
4347
deprecations: [],
4448
entities: [],
4549
sections: [],
@@ -111,6 +115,8 @@ defmodule Spark.Dsl.Section do
111115

112116
@type auto_set_fields() :: keyword(any)
113117

118+
@type singleton_entity_keys :: [atom]
119+
114120
@type entities :: [Entity.t()]
115121

116122
@type sections :: [Section.t()]
@@ -134,6 +140,7 @@ defmodule Spark.Dsl.Section do
134140
modules: modules(),
135141
no_depend_modules: no_depend_modules(),
136142
auto_set_fields: auto_set_fields(),
143+
singleton_entity_keys: singleton_entity_keys(),
137144
entities: entities(),
138145
sections: sections(),
139146
docs: docs(),
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: 2022 spark contributors <https://github.com/ash-project/spark/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule Spark.Dsl.Verifiers.VerifySectionSingletonEntities do
6+
@moduledoc """
7+
Verifies that entities specified in a section's `singleton_entity_keys`
8+
appear at most once within that section.
9+
"""
10+
11+
use Spark.Dsl.Verifier
12+
13+
alias Spark.Dsl.Verifier
14+
15+
def verify(dsl_state) do
16+
module = Verifier.get_persisted(dsl_state, :module)
17+
18+
dsl_state
19+
|> Verifier.get_persisted(:extensions)
20+
|> Enum.each(fn extension ->
21+
Enum.each(extension.sections(), fn section ->
22+
verify_section(module, section, dsl_state)
23+
end)
24+
end)
25+
26+
:ok
27+
end
28+
29+
defp verify_section(module, section, dsl_state, path \\ []) do
30+
section_path = path ++ [section.name]
31+
32+
case section.singleton_entity_keys do
33+
[] ->
34+
:ok
35+
36+
singleton_keys ->
37+
entities = Verifier.get_entities(dsl_state, section_path)
38+
39+
Enum.each(singleton_keys, fn key ->
40+
entity_def = Enum.find(section.entities, &(&1.name == key))
41+
42+
if entity_def do
43+
matching =
44+
Enum.filter(entities, &(&1.__struct__ == entity_def.target))
45+
46+
if length(matching) > 1 do
47+
location =
48+
case matching do
49+
[_, second | _] -> Spark.Dsl.Entity.anno(second)
50+
_ -> nil
51+
end
52+
53+
raise Spark.Error.DslError,
54+
module: module,
55+
path: section_path,
56+
message:
57+
"Expected at most one #{key} in #{inspect(section_path)}, got #{length(matching)}",
58+
location: location
59+
end
60+
end
61+
end)
62+
end
63+
64+
Enum.each(section.sections, fn nested_section ->
65+
verify_section(module, nested_section, dsl_state, section_path)
66+
end)
67+
end
68+
end

test/dsl_test.exs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,55 @@ defmodule Spark.DslTest do
364364
end
365365
end
366366
end
367+
368+
test "section singleton_entity_keys allows zero entities" do
369+
defmodule SectionSingletonZero do
370+
@moduledoc false
371+
use Spark.Test.Contact
372+
373+
personal_details do
374+
first_name("Zach")
375+
end
376+
end
377+
end
378+
379+
test "section singleton_entity_keys allows one entity" do
380+
defmodule SectionSingletonOne do
381+
@moduledoc false
382+
use Spark.Test.Contact
383+
384+
singleton_section do
385+
singleton(:only_one)
386+
end
387+
388+
personal_details do
389+
first_name("Zach")
390+
end
391+
end
392+
393+
assert [%Spark.Test.Contact.Dsl.Singleton{value: :only_one}] =
394+
Spark.Dsl.Extension.get_entities(SectionSingletonOne, [:singleton_section])
395+
end
396+
397+
test "section singleton_entity_keys rejects multiple entities" do
398+
import ExUnit.CaptureIO
399+
400+
assert capture_io(:stderr, fn ->
401+
defmodule SectionSingletonMultiple do
402+
@moduledoc false
403+
use Spark.Test.Contact
404+
405+
singleton_section do
406+
singleton(:first)
407+
singleton(:second)
408+
end
409+
410+
personal_details do
411+
first_name("Zach")
412+
end
413+
end
414+
end) =~ "Expected at most one singleton"
415+
end
367416
end
368417

369418
describe "annotation tracking" do

test/support/contact/contact.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,12 @@ defmodule Spark.Test.Contact do
256256
]
257257
}
258258

259+
@singleton_section %Spark.Dsl.Section{
260+
name: :singleton_section,
261+
entities: [@singleton],
262+
singleton_entity_keys: [:singleton]
263+
}
264+
259265
@awesome_status %Spark.Dsl.Section{
260266
name: :awesome?,
261267
schema: [
@@ -276,7 +282,14 @@ defmodule Spark.Test.Contact do
276282
end
277283

278284
use Spark.Dsl.Extension,
279-
sections: [@contact, @personal_details, @address, @presets, @awesome_status],
285+
sections: [
286+
@contact,
287+
@personal_details,
288+
@address,
289+
@presets,
290+
@singleton_section,
291+
@awesome_status
292+
],
280293
verifiers: [Spark.Test.Contact.Verifiers.VerifyNotGandalf],
281294
transformers: [Transformer]
282295

0 commit comments

Comments
 (0)