Skip to content

Commit

Permalink
Merge branch 'optionality-in-schemas'
Browse files Browse the repository at this point in the history
  • Loading branch information
keathley committed Dec 2, 2019
2 parents 4ee2f5a + b6d07e4 commit fe5fc68
Show file tree
Hide file tree
Showing 7 changed files with 344 additions and 236 deletions.
75 changes: 53 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@ conform!(%{user: %{name: "chris", age: -31}}, user_schema)
(norm) lib/norm.ex:44: Norm.conform!/2
```

Schema's are designed to allow systems to grow over time. They provide this
functionality in two ways. The first is that any unspecified fields in the input
are passed through when conforming the input. The second is that all keys in a
schema are optional. This means that all of these are valid:

```elixir
user_schema = schema(%{
name: spec(is_binary()),
age: spec(is_integer()),
})

conform!(%{}, user_schema)
=> %{}
conform!(%{age: 31}, user_schema)
=> %{age: 31}
conform!(%{foo: :foo, bar: :bar}, user_schema)
=> %{foo: :foo, bar: :bar}
```

If you're used to more restrictive systems for managing data these might seem
like odd choices. We'll see how to specify required keys when we discuss Selections.

#### Structs

You can also create specs from structs:

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

### Selections
### Selections and optionality

You may have noticed that there's no way to specify optional keys in
a schema. This may seem like an oversight but its actually an intentional
design decision. Whether a key should be present in a schema is determined
by the call site and not by the schema itself. For instance think about
the assigns in a plug conn. When are the assigns optional? It depends on
where you are in the pipeline.

Schemas also force all keys to match at all times. This is generally
useful as it limits your ability to introduce errors. But it also limits
schema growth and turns changes that should be non-breaking into breaking
changes.

In order to support both of these scenarios Norm provides the
`selection/2` function. `selection/2` allows you to specify exactly the
keys you require from a schema at the place where you require them.
We said that all of the fields in a schema are optional. In order to specify
the keys that are required in a specific use case we can use a Selection. The
Selections takes a schema and a list of keys - or keys to lists of keys - that
must be present in the schema.

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

conform!(%{user: %{name: "chris", age: 31}}, just_age)
=> %{user: %{age: 31}}
=> %{user: %{age: 31, name: "chris"}}

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

If you need to mark all fields in a schema as required you can elide the list
of keys like so:

```elixir
user_schema = schema(%{
user: schema(%{
name: spec(is_binary()),
age: spec(is_integer()),
})
})

# Require all fields recursively
conform!(%{user: %{name: "chris", age: 31}}, selection(user_schema))
```

Selections are an important tool because they give control over optionality
back to the call site. This allows callers to determine what they actually need
and makes schema's much more reusable.

### Patterns

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

- [ ] Support generators for other primitive types (floats, etc.)
- [ ] More streamlined specification of keyword lists.
- [ ] selections shouldn't need a path if you just want to match all the keys in the schema
- [ ] Support "sets" of literal values
- [ ] specs for functions and anonymous functions
- [ ] easier way to do dispatch based on schema keys
66 changes: 49 additions & 17 deletions lib/norm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -478,19 +478,64 @@ defmodule Norm do
end

@doc ~S"""
Creates a re-usable schema.
Creates a re-usable schema. Schema's are open which means that all keys are
optional and any non-specified keys are passed through without being conformed.
If you need to mark keys as required instead of optional you can use `selection`.
## Examples
iex> conform!(%{age: 31, name: "chris"},
...> schema(%{age: spec(is_integer()), name: spec(is_binary())})
...> )
iex> valid?(%{}, schema(%{name: spec(is_binary())}))
true
iex> valid?(%{name: "Chris"}, schema(%{name: spec(is_binary())}))
true
iex> valid?(%{name: "Chris", age: 31}, schema(%{name: spec(is_binary())}))
true
iex> valid?(%{age: 31}, schema(%{name: spec(is_binary())}))
true
iex> valid?(%{name: 123}, schema(%{name: spec(is_binary())}))
false
iex> conform!(%{}, schema(%{name: spec(is_binary())}))
%{}
iex> conform!(%{age: 31, name: "chris"}, schema(%{name: spec(is_binary())}))
%{age: 31, name: "chris"}
iex> conform!(%{age: 31}, schema(%{name: spec(is_binary())}))
%{age: 31}
iex> conform!(%{user: %{name: "chris"}}, schema(%{user: schema(%{name: spec(is_binary())})}))
%{user: %{name: "chris"}}
"""
def schema(input) when is_map(input) do
Schema.build(input)
end

@doc ~S"""
Selections can be used to mark keys on a schema as required. Any unspecified keys
in the selection are still considered optional. Selections, like schemas,
are open and allow unspecied keys to be passed through. If no selectors are
provided then `selection` defaults to `:all` and recursively marks all keys in
all nested schema's. If the schema includes internal selections these selections
will not be overwritten.
## Examples
iex> valid?(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
true
iex> valid?(%{}, selection(schema(%{name: spec(is_binary())}), [:name]))
false
iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})}), [user: [:name]]))
true
iex> conform!(%{name: "chris"}, selection(schema(%{name: spec(is_binary())}), [:name]))
%{name: "chris"}
iex> conform!(%{name: "chris", age: 31}, selection(schema(%{name: spec(is_binary())}), [:name]))
%{name: "chris", age: 31}
## Require all keys
iex> valid?(%{user: %{name: "chris"}}, selection(schema(%{user: schema(%{name: spec(is_binary())})})))
true
"""
def selection(%Schema{} = schema, path \\ :all) do
Selection.new(schema, path)
end

@doc ~S"""
Chooses between alternative predicates or patterns. The patterns must be tagged with an atom.
When conforming data to this specification the data is returned as a tuple with the tag.
Expand Down Expand Up @@ -527,19 +572,6 @@ defmodule Norm do
Union.new(specs)
end

@doc ~S"""
Selections provide a way to allow optional keys in a schema. This allows
schema's to be defined once and re-used in multiple scenarios.
## Examples
iex> conform!(%{age: 31}, selection(schema(%{age: spec(is_integer()), name: spec(is_binary())}), [:age]))
%{age: 31}
"""
def selection(%Schema{} = schema, path) do
Selection.new(schema, path)
end

@doc ~S"""
Specifies a generic collection. Collections can be any enumerable type.
Expand Down
60 changes: 24 additions & 36 deletions lib/norm/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Norm.Schema do

alias __MODULE__

defstruct specs: [], struct: nil
defstruct specs: %{}, struct: nil

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

%Schema{specs: specs, struct: name}
end

def build(map) when is_map(map) do
specs =
map
|> Enum.to_list()

%Schema{specs: specs}
%Schema{specs: map}
end

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

# Conforming a struct
def conform(%{specs: specs, struct: target}, input, path) when not is_nil(target) do
# Ensure we're mapping the correct struct
if Map.get(input, :__struct__) == target do
with {:ok, conformed} <- check_specs(specs, input, path) do
{:ok, struct(target, conformed)}
end
else
short_name =
target
|> Atom.to_string()
|> String.replace("Elixir.", "")
cond do
Map.get(input, :__struct__) != target ->
short_name =
target
|> Atom.to_string()
|> String.replace("Elixir.", "")

{:error, [Conformer.error(path, input, "#{short_name}")]}

{:error, [Conformer.error(path, input, "#{short_name}")]}
true ->
with {:ok, conformed} <- check_specs(specs, Map.from_struct(input), path) do
{:ok, struct(target, conformed)}
end
end
end

# conforming a map.
def conform(%Norm.Schema{specs: specs}, input, path) do
check_specs(specs, input, path)
end

defp check_specs(specs, input, path) do
results =
specs
|> Enum.map(&check_spec(&1, input, path))
input
|> Enum.map(&check_spec(&1, specs, path))
|> Enum.reduce(%{ok: [], error: []}, fn {key, {result, conformed}}, acc ->
Map.put(acc, result, acc[result] ++ [{key, conformed}])
end)
Expand All @@ -80,24 +79,13 @@ defmodule Norm.Schema do
end
end

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

true ->
{key, {:ok, Map.get(input, key)}}
end
end

defp check_spec({key, spec}, input, path) do
case Map.has_key?(input, key) do
false ->
{key, {:error, [Conformer.error(path ++ [key], input, ":required")]}}

true ->
val = Map.get(input, key)
{key, Conformable.conform(spec, val, path ++ [key])}
spec ->
{key, Conformable.conform(spec, value, path ++ [key])}
end
end
end
Expand Down
Loading

0 comments on commit fe5fc68

Please sign in to comment.