Skip to content

Commit

Permalink
Document unions and make then return more clever error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
colinsmetz committed Oct 21, 2021
1 parent 67183cf commit 0dff175
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 49 deletions.
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,18 +257,26 @@ end

### Unions

Unions can be defined using 1-arity functions that decide which schema
to use based on the input data:
Unions can be defined in two ways:

```elixir
%{
# Metadata is either a map with string values, or a list of strings
"metadata" => fn
%{} -> map(%{any_key() => string()})
[_ | _] -> list(string())
end
}
```
1. Using the `union/1` helper:

```elixir
union([string(non_empty: true), number(min: 0)])
```

2. Using 1-arity functions that decide which schema to use based on the input
data:

```elixir
%{
# Metadata is either a map with string values, or a list of strings
"metadata" => fn
%{} -> map(%{any_key() => string()})
[_ | _] -> list(string())
end
}
```

### Casting

Expand Down
84 changes: 60 additions & 24 deletions guides/schema_definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,38 +361,74 @@ end

Then you can call `validate(tree, tree_schema())` as usual.

## Union types / Case type
## Unions

Union types are supported as such. However, there are multiple ways a field can
have different types (without matching *anything* with `value()`):
* Using `cast_from` (see "Casting" section above).
* Using selectors
Sometimes, a value can take multiple possible forms. In this case, it is useful
to specify a union of possible types. This possible to some extent using the
catch-all helper `value()` or using `cast_from`, but those are not exactly
unions.

Selectors are defined using a 1-arity function taking the current data as a
parameter. This data can be used to decide on which schema should be used. For
example:
Validator offers two better ways to do unions of specific schemas.

### The `union` helper

The `union/1` helper takes a list of accepted schemas. A value is valid if it
matches any of the schemas. If none of them matches, errors are returned
according to the following rules:

* If the value matches the primary type of a single schema (not more than one),
then the errors for that schema are returned.
* Otherwise, a generic error message is returned, listing the potential types.

The idea is to provide more specific error messages when it is very likely that
the user was aiming for one of the schemas in particular.

```elixir
schema = %{
a: fn
%{} -> %{b: number(), c: number()}
l when is_list(l) -> list(number())
end
}
iex> Validator.validate("hello", union([string(), atom()]))
[]

iex> Validator.validate(:hello, union([string(), atom()]))
[]

iex> Validator.validate(15, union([string(), atom()]))
[%Validator.Error{context: [], message: "The value does not match any schema in the union. Possible types: [:string, :atom]."}]

iex> Validator.validate(15, union([number(max: 10), string()]))
[%Validator.Error{context: [], message: "Must be less than or equal to 10."}]
```

This schema would accept field `:a` to be either:
* A map with schema `%{b: number(), c: number()}`
* A list of numbers
### Pattern matching on the value

The `union` helper is handy but it often fails to provide specific error messages
as there is no way to choose between the schemas in the union most of the time.
An alternative could be to return the errors for *all possible schemas*, but this
would likely be confusing.

To solve that problem, you can define unions using 1-arity functions. The function
will receive the value as input, so that you can pattern match on it, and select
a schema.

If the input data is not a map or a list, then it is considered an error and
none of the subschemas is tested.
```elixir
schema = fn
%{type: "car"} ->
%{
type: string(),
fuel_type: string(),
model: string()
}

%{type: "bike"} ->
%{
type: string(),
electric: boolean(),
brake_type: string()
}
end
```

Compared to a potential generic `union([typeA, typeB])` function, this method
has the advantage that we know which schema is expected to be used, so we can
return more specific errors corresponding to the selected subschemas. If we
didn't know, we'd have to either return a single generic error, or the errors
for both schemas, which would be confusing.
This schema would accept either one map or the other depending on the value for
`:type`. If the value doesn't match any of the definitions, a generic error
message in returned.

## Replace errors with `on_error` option

Expand Down
39 changes: 27 additions & 12 deletions lib/validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,28 @@ defmodule Validator do
end
end)
|> case do
[] -> []
_errors -> [Error.new("The value does not match any schema in the union.", context)]
[] ->
[]

errors ->
schemas
|> Enum.map(&check_type_and_cast_if_needed(value, &1, context))
|> Enum.zip(Enum.reverse(errors))
|> Enum.reject(fn result -> match?({{:error, _}, _}, result) end)
|> case do
[{{:ok, _}, specific_errors}] ->
specific_errors

_ ->
schemas_types = schemas |> Enum.map(&type_of_schema/1) |> Enum.uniq()

[
Error.new(
"The value does not match any schema in the union. Possible types: #{inspect(schemas_types)}.",
context
)
]
end
end
end

Expand Down Expand Up @@ -514,19 +534,14 @@ defmodule Validator do
type =
schema
|> Map.get(field)
|> case do
%Spec{type: type} ->
type

value when is_function(value) ->
nil

value ->
Types.type_of(value)
end
|> type_of_schema()

if type, do: "#{inspect(field)} (#{type})", else: inspect(field)
end)
|> Enum.join(", ")
end

defp type_of_schema(%Spec{type: type}), do: type
defp type_of_schema(schema) when is_function(schema), do: nil
defp type_of_schema(schema), do: Types.type_of(schema)
end
22 changes: 21 additions & 1 deletion lib/validator/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,27 @@ defmodule Validator.Helpers do
defdelegate time(opts \\ []), to: Validator.Types.Time

@doc """
TODO
Represents the union of multiple schemas.
If the value matches any of the given schemas, it is considered valid. However,
if none of them matches, errors are returned. If the value matches the primary
type (map, string, number, etc.) of a single schema in the list, then the errors
for that schema are returned. Otherwise, a generic error is returned.
## Examples
iex> Validator.validate("hello", union([string(), atom()]))
[]
iex> Validator.validate(:hello, union([string(), atom()]))
[]
iex> Validator.validate(15, union([string(), atom()]))
[%Validator.Error{context: [], message: "The value does not match any schema in the union. Possible types: [:string, :atom]."}]
iex> Validator.validate(15, union([number(max: 10), string()]))
[%Validator.Error{context: [], message: "Must be less than or equal to 10."}]
"""
@spec union(list(Validator.spec_type())) :: Validator.Union.t()
def union(schemas) do
Expand Down
42 changes: 41 additions & 1 deletion test/validator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1254,8 +1254,48 @@ defmodule ValidatorTest do
assert validate(18, schema) == []

assert validate(:boo, schema) == [
Error.new("The value does not match any schema in the union.", [])
Error.new(
"The value does not match any schema in the union. Possible types: [:string, :number].",
[]
)
]
end

test "if it fails but matches the primary type of a single schema in the union, it returns the errors for that schema" do
schema = union([string(min_length: 10), number(min: 10), atom()])

assert validate("hello", schema) == [
Error.new("Minimum length of 10 required (current: 5).", [])
]

assert validate(5, schema) == [Error.new("Must be greater than or equal to 10.", [])]
assert validate(:ok, schema) == []

assert validate(%{a: 1}, schema) == [
Error.new(
"The value does not match any schema in the union. Possible types: [:string, :number, :atom].",
[]
)
]
end

test "if it matches the primary type of more than one schema, it returns a generic error" do
schema = union([number(min: 0), float(min: 0), string(non_empty: true)])

assert validate(-9.9, schema) == [
Error.new(
"The value does not match any schema in the union. Possible types: [:number, :float, :string].",
[]
)
]

assert validate("", schema) == [Error.new("Value cannot be empty.", [])]
end

test "it takes cast_from into account" do
schema = union([number(min: 10, cast_from: :string), atom()])

assert validate("5", schema) == [Error.new("Must be greater than or equal to 10.", [])]
end
end
end

0 comments on commit 0dff175

Please sign in to comment.