Skip to content

Commit fe5fc68

Browse files
committed
Merge branch 'optionality-in-schemas'
2 parents 4ee2f5a + b6d07e4 commit fe5fc68

File tree

7 files changed

+344
-236
lines changed

7 files changed

+344
-236
lines changed

README.md

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,30 @@ conform!(%{user: %{name: "chris", age: -31}}, user_schema)
142142
(norm) lib/norm.ex:44: Norm.conform!/2
143143
```
144144

145+
Schema's are designed to allow systems to grow over time. They provide this
146+
functionality in two ways. The first is that any unspecified fields in the input
147+
are passed through when conforming the input. The second is that all keys in a
148+
schema are optional. This means that all of these are valid:
149+
150+
```elixir
151+
user_schema = schema(%{
152+
name: spec(is_binary()),
153+
age: spec(is_integer()),
154+
})
155+
156+
conform!(%{}, user_schema)
157+
=> %{}
158+
conform!(%{age: 31}, user_schema)
159+
=> %{age: 31}
160+
conform!(%{foo: :foo, bar: :bar}, user_schema)
161+
=> %{foo: :foo, bar: :bar}
162+
```
163+
164+
If you're used to more restrictive systems for managing data these might seem
165+
like odd choices. We'll see how to specify required keys when we discuss Selections.
166+
167+
#### Structs
168+
145169
You can also create specs from structs:
146170

147171
```elixir
@@ -183,23 +207,12 @@ Schemas accomodate growth by disregarding any unspecified keys in the input map.
183207
This allows callers to start sending new data over time without coordination
184208
with the consuming function.
185209

186-
### Selections
210+
### Selections and optionality
187211

188-
You may have noticed that there's no way to specify optional keys in
189-
a schema. This may seem like an oversight but its actually an intentional
190-
design decision. Whether a key should be present in a schema is determined
191-
by the call site and not by the schema itself. For instance think about
192-
the assigns in a plug conn. When are the assigns optional? It depends on
193-
where you are in the pipeline.
194-
195-
Schemas also force all keys to match at all times. This is generally
196-
useful as it limits your ability to introduce errors. But it also limits
197-
schema growth and turns changes that should be non-breaking into breaking
198-
changes.
199-
200-
In order to support both of these scenarios Norm provides the
201-
`selection/2` function. `selection/2` allows you to specify exactly the
202-
keys you require from a schema at the place where you require them.
212+
We said that all of the fields in a schema are optional. In order to specify
213+
the keys that are required in a specific use case we can use a Selection. The
214+
Selections takes a schema and a list of keys - or keys to lists of keys - that
215+
must be present in the schema.
203216

204217
```elixir
205218
user_schema = schema(%{
@@ -211,13 +224,33 @@ user_schema = schema(%{
211224
just_age = selection(user_schema, [user: [:age]])
212225

213226
conform!(%{user: %{name: "chris", age: 31}}, just_age)
214-
=> %{user: %{age: 31}}
227+
=> %{user: %{age: 31, name: "chris"}}
215228

216-
# Selection also disregards unspecified keys
217-
conform!(%{user: %{name: "chris", age: 31, unspecified: nil}, other_stuff: :foo}, just_age)
218-
=> %{user: %{age: 31}}
229+
conform!(%{user: %{name: "chris"}}, just_age)
230+
** (Norm.MismatchError) Could not conform input:
231+
val: %{name: "chris"} in: :user/:age fails: :required
232+
(norm) lib/norm.ex:387: Norm.conform!/2
219233
```
220234

235+
If you need to mark all fields in a schema as required you can elide the list
236+
of keys like so:
237+
238+
```elixir
239+
user_schema = schema(%{
240+
user: schema(%{
241+
name: spec(is_binary()),
242+
age: spec(is_integer()),
243+
})
244+
})
245+
246+
# Require all fields recursively
247+
conform!(%{user: %{name: "chris", age: 31}}, selection(user_schema))
248+
```
249+
250+
Selections are an important tool because they give control over optionality
251+
back to the call site. This allows callers to determine what they actually need
252+
and makes schema's much more reusable.
253+
221254
### Patterns
222255

223256
Norm provides a way to specify alternative specs using the `alt/1`
@@ -363,9 +396,7 @@ working to make improvements.
363396
Norm is being actively worked on. Any contributions are very welcome. Here is a
364397
limited set of ideas that are coming soon.
365398

366-
- [ ] Support generators for other primitive types (floats, etc.)
367399
- [ ] More streamlined specification of keyword lists.
368-
- [ ] selections shouldn't need a path if you just want to match all the keys in the schema
369400
- [ ] Support "sets" of literal values
370401
- [ ] specs for functions and anonymous functions
371402
- [ ] easier way to do dispatch based on schema keys

lib/norm.ex

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -478,19 +478,64 @@ defmodule Norm do
478478
end
479479

480480
@doc ~S"""
481-
Creates a re-usable schema.
481+
Creates a re-usable schema. Schema's are open which means that all keys are
482+
optional and any non-specified keys are passed through without being conformed.
483+
If you need to mark keys as required instead of optional you can use `selection`.
482484
483485
## Examples
484486
485-
iex> conform!(%{age: 31, name: "chris"},
486-
...> schema(%{age: spec(is_integer()), name: spec(is_binary())})
487-
...> )
487+
iex> valid?(%{}, schema(%{name: spec(is_binary())}))
488+
true
489+
iex> valid?(%{name: "Chris"}, schema(%{name: spec(is_binary())}))
490+
true
491+
iex> valid?(%{name: "Chris", age: 31}, schema(%{name: spec(is_binary())}))
492+
true
493+
iex> valid?(%{age: 31}, schema(%{name: spec(is_binary())}))
494+
true
495+
iex> valid?(%{name: 123}, schema(%{name: spec(is_binary())}))
496+
false
497+
iex> conform!(%{}, schema(%{name: spec(is_binary())}))
498+
%{}
499+
iex> conform!(%{age: 31, name: "chris"}, schema(%{name: spec(is_binary())}))
488500
%{age: 31, name: "chris"}
501+
iex> conform!(%{age: 31}, schema(%{name: spec(is_binary())}))
502+
%{age: 31}
503+
iex> conform!(%{user: %{name: "chris"}}, schema(%{user: schema(%{name: spec(is_binary())})}))
504+
%{user: %{name: "chris"}}
489505
"""
490506
def schema(input) when is_map(input) do
491507
Schema.build(input)
492508
end
493509

510+
@doc ~S"""
511+
Selections can be used to mark keys on a schema as required. Any unspecified keys
512+
in the selection are still considered optional. Selections, like schemas,
513+
are open and allow unspecied keys to be passed through. If no selectors are
514+
provided then `selection` defaults to `:all` and recursively marks all keys in
515+
all nested schema's. If the schema includes internal selections these selections
516+
will not be overwritten.
517+
518+
## Examples
519+
520+
iex> valid?(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
521+
true
522+
iex> valid?(%{}, selection(schema(%{name: spec(is_binary())}), [:name]))
523+
false
524+
iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}), [user: [:name]]))
525+
true
526+
iex> conform!(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
527+
%{name: "chris"}
528+
iex> conform!(%{name: "chris", age: 31}, selection(schema(%{name: spec(is_binary())}), [:name]))
529+
%{name: "chris", age: 31}
530+
531+
## Require all keys
532+
iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})})))
533+
true
534+
"""
535+
def selection(%Schema{} = schema, path \\ :all) do
536+
Selection.new(schema, path)
537+
end
538+
494539
@doc ~S"""
495540
Chooses between alternative predicates or patterns. The patterns must be tagged with an atom.
496541
When conforming data to this specification the data is returned as a tuple with the tag.
@@ -527,19 +572,6 @@ defmodule Norm do
527572
Union.new(specs)
528573
end
529574

530-
@doc ~S"""
531-
Selections provide a way to allow optional keys in a schema. This allows
532-
schema's to be defined once and re-used in multiple scenarios.
533-
534-
## Examples
535-
536-
iex> conform!(%{age: 31}, selection(schema(%{age: spec(is_integer()), name: spec(is_binary())}), [:age]))
537-
%{age: 31}
538-
"""
539-
def selection(%Schema{} = schema, path) do
540-
Selection.new(schema, path)
541-
end
542-
543575
@doc ~S"""
544576
Specifies a generic collection. Collections can be any enumerable type.
545577

lib/norm/schema.ex

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Norm.Schema do
44

55
alias __MODULE__
66

7-
defstruct specs: [], struct: nil
7+
defstruct specs: %{}, struct: nil
88

99
# If we're building a schema from a struct then we need to add a default spec
1010
# for each key that only checks for presence. This allows users to specify
@@ -13,17 +13,12 @@ defmodule Norm.Schema do
1313
specs =
1414
struct
1515
|> Map.from_struct()
16-
|> Enum.to_list()
1716

1817
%Schema{specs: specs, struct: name}
1918
end
2019

2120
def build(map) when is_map(map) do
22-
specs =
23-
map
24-
|> Enum.to_list()
25-
26-
%Schema{specs: specs}
21+
%Schema{specs: map}
2722
end
2823

2924
def spec(schema, key) do
@@ -41,30 +36,34 @@ defmodule Norm.Schema do
4136
{:error, [Conformer.error(path, input, "not a map")]}
4237
end
4338

39+
# Conforming a struct
4440
def conform(%{specs: specs, struct: target}, input, path) when not is_nil(target) do
4541
# Ensure we're mapping the correct struct
46-
if Map.get(input, :__struct__) == target do
47-
with {:ok, conformed} <- check_specs(specs, input, path) do
48-
{:ok, struct(target, conformed)}
49-
end
50-
else
51-
short_name =
52-
target
53-
|> Atom.to_string()
54-
|> String.replace("Elixir.", "")
42+
cond do
43+
Map.get(input, :__struct__) != target ->
44+
short_name =
45+
target
46+
|> Atom.to_string()
47+
|> String.replace("Elixir.", "")
48+
49+
{:error, [Conformer.error(path, input, "#{short_name}")]}
5550

56-
{:error, [Conformer.error(path, input, "#{short_name}")]}
51+
true ->
52+
with {:ok, conformed} <- check_specs(specs, Map.from_struct(input), path) do
53+
{:ok, struct(target, conformed)}
54+
end
5755
end
5856
end
5957

58+
# conforming a map.
6059
def conform(%Norm.Schema{specs: specs}, input, path) do
6160
check_specs(specs, input, path)
6261
end
6362

6463
defp check_specs(specs, input, path) do
6564
results =
66-
specs
67-
|> Enum.map(&check_spec(&1, input, path))
65+
input
66+
|> Enum.map(&check_spec(&1, specs, path))
6867
|> Enum.reduce(%{ok: [], error: []}, fn {key, {result, conformed}}, acc ->
6968
Map.put(acc, result, acc[result] ++ [{key, conformed}])
7069
end)
@@ -80,24 +79,13 @@ defmodule Norm.Schema do
8079
end
8180
end
8281

83-
defp check_spec({key, nil}, input, path) do
84-
case Map.has_key?(input, key) do
85-
false ->
86-
{key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}
82+
defp check_spec({key, value}, specs, path) do
83+
case Map.get(specs, key) do
84+
nil ->
85+
{key, {:ok, value}}
8786

88-
true ->
89-
{key, {:ok, Map.get(input, key)}}
90-
end
91-
end
92-
93-
defp check_spec({key, spec}, input, path) do
94-
case Map.has_key?(input, key) do
95-
false ->
96-
{key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}
97-
98-
true ->
99-
val = Map.get(input, key)
100-
{key, Conformable.conform(spec, val, path ++ [key])}
87+
spec ->
88+
{key, Conformable.conform(spec, value, path ++ [key])}
10189
end
10290
end
10391
end

0 commit comments

Comments
 (0)