Skip to content

Commit

Permalink
Better infra for Types (#15)
Browse files Browse the repository at this point in the history
* Behaviour + sample impl for `Date`

* Fix GHA

* Types for date/time and IP

* Types for IP/URI and SCaffold for enum/tags

* Credo + Dialyzer
  • Loading branch information
am-kantox authored Jan 20, 2025
1 parent 76cd0ba commit fff43b7
Show file tree
Hide file tree
Showing 16 changed files with 758 additions and 29 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
matrix:
include:
- pair:
otp: 24.2
otp: 25
elixir: 1.14
- pair:
otp: 27
Expand Down
22 changes: 22 additions & 0 deletions lib/estructura/coercers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,28 @@ defmodule Estructura.Coercers.NullableTime do
end

defmodule Estructura.Coercers.Datetime do
@moduledoc deprecated: "Use `Estructura.Coercers.DateTime` instead"
@moduledoc "Default coercer for `:datetime`, coercing strings (_ISO8601_) and integers (_epoch_)"

@behaviour Estructura.Coercer
@impl Estructura.Coercer

def coerce(%DateTime{} = value), do: {:ok, value}

def coerce(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, result, 0} -> {:ok, result}
{:ok, _result, offset} -> {:error, "Unsupported offset (#{offset})"}
error -> error
end
end

def coerce(value) when is_integer(value) do
DateTime.from_unix(value)
end
end

defmodule Estructura.Coercers.DateTime do
@moduledoc "Default coercer for `:datetime`, coercing strings (_ISO8601_) and integers (_epoch_)"

@behaviour Estructura.Coercer
Expand Down
119 changes: 112 additions & 7 deletions lib/estructura/nested.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ defmodule Estructura.Nested do
"""

@actions ~w|coerce validate generate|a
@simple_parametrized_types ~w|integer float time date datetime constant string|a
@metas [Estructura.Nested.Type.Enum, Estructura.Nested.Type.Tags]

@typep action :: :coerce | :validate | :generate
@typep functions :: [{:coerce, atom()} | {:validate, atom()} | {:generate, atom()}]
@typep definitions :: %{defs: Macro.input(), funs: functions()}
@typep mfargs :: {module(), atom(), list()}
@typep simple_type_variants :: atom() | {atom(), any()} | mfargs()
@typep simple_type :: {:simple, simple_type_variants()}
@typep estructura_type :: {:estructura, module()}
@typep shape :: %{required(atom()) => simple_type() | estructura_type()}
@typep shape :: %{required(atom()) => simple_type() | {:estructura, module()}}

alias Estructura.Nested.Type.Scaffold

@doc false
defmacro __using__(opts \\ []) do
Expand Down Expand Up @@ -334,7 +337,7 @@ defmodule Estructura.Nested do
{_, %{}}, acc -> acc
{name, [type]}, acc -> Map.put(acc, name, {:list, type})
{name, [_ | _] = types}, acc -> Map.put(acc, name, {:mixed, types})
{name, type}, acc -> Map.put(acc, name, {:simple, type})
{name, type}, acc -> Map.put(acc, name, type(type))
end)
|> Map.merge(complex)

Expand All @@ -346,6 +349,26 @@ defmodule Estructura.Nested do
end
end

@spec type(type) :: {:simple | :remote | :type, type} when type: simple_type_variants()
defp type({simple, _} = type) when simple in @simple_parametrized_types, do: {:simple, type}

defp type(type)
when is_atom(type)
when is_tuple(type) and tuple_size(type) == 2 and is_atom(elem(type, 0)) do
type
|> case do
{type, _} -> type
type -> type
end
|> Atom.to_string()
|> case do
"Elixir.Estructura.Nested.Type." <> _ -> {:type, type}
_ -> {:simple, type}
end
end

defp type(type) when not is_atom(type), do: {:remote, type}

@spec coercions_and_validations(functions()) :: {[atom()], [atom()]}
defp coercions_and_validations(funs) do
{
Expand All @@ -363,6 +386,9 @@ defmodule Estructura.Nested do
{name, {:list, _type}} -> {name, Map.get(values, name, [])}
{name, {:mixed, _types}} -> {name, Map.get(values, name, [])}
{name, {:simple, _type}} -> {name, Map.get(values, name, nil)}
# [TODO] [AM]
{name, {:remote, _type}} -> {name, Map.get(values, name, nil)}
{name, {:type, _type}} -> {name, Map.get(values, name, nil)}
{name, {:estructura, module}} -> {name, struct!(module, Map.get(values, name, %{}))}
end)
|> Estructura.recalculate_calculated(calculated)
Expand Down Expand Up @@ -390,6 +416,13 @@ defmodule Estructura.Nested do
{name, {:simple, _type}} ->
{name, {:any, [], []}}

{name, {:remote, _type}} ->
{name, {:any, [], []}}

# [AM] [TODO] Make it explicitly refer to `Type.t`
{name, {:type, _type}} ->
{name, {:any, [], []}}

{name, {:estructura, module}} ->
{name,
{{:., [],
Expand All @@ -403,10 +436,30 @@ defmodule Estructura.Nested do
@spec generator_ast(shape()) :: [{atom(), mfargs()}]
defp generator_ast(fields) do
Enum.map(fields, fn
{name, {:list, type}} -> {name, stream_data_type_for([type])}
{name, {:mixed, types}} -> {name, stream_data_type_for(types)}
{name, {:simple, type}} -> {name, stream_data_type_for(type)}
{name, {:estructura, module}} -> {name, {module, :__generator__, []}}
{name, {:list, type}} ->
{name, stream_data_type_for([type])}

{name, {:mixed, types}} ->
{name, stream_data_type_for(types)}

{name, {:simple, type}} ->
{name, stream_data_type_for(type)}

{name, {:remote, type}} ->
{name, stream_data_type_for(type)}

{name, {:type, {type, opts}}} when type in @metas ->
with {type, opts} <- get_name_opts(name, type, opts),
do: {name, {type, :generate, [opts]}}

{name, {:type, {type, opts}}} ->
{name, {type, :generate, [opts]}}

{name, {:type, type}} ->
{name, {type, :generate, []}}

{name, {:estructura, module}} ->
{name, {module, :__generator__, []}}
end)
end

Expand Down Expand Up @@ -445,8 +498,60 @@ defmodule Estructura.Nested do
defp stream_data_type_for(const),
do: {StreamData, :constant, [const]}

@spec coercer_and_validator(atom(), module()) :: Macro.t()
defp coercer_and_validator(field, type) do
quote generated: true, location: :keep do
@impl true
def unquote(:"coerce_#{field}")(value),
do: unquote(type).coerce(value)

@impl true
def unquote(:"validate_#{field}")(value),
do: unquote(type).validate(value)
end
end

@spec generate_name(field :: atom() | binary(), term(), term()) :: module()
defp generate_name(atom, type, opts) when is_atom(atom),
do: atom |> to_string() |> generate_name(type, opts)

defp generate_name(string, type, opts) when is_binary(string) do
Module.concat(
Estructura.Nested.Type,
"#{Macro.camelize(string)}_#{:erlang.phash2({type, opts})}"
)
end

@spec get_name_opts(atom(), module(), opts) :: {module(), opts} when opts: term()
defp get_name_opts(field, type, options) do
if Keyword.keyword?(options),
do: Keyword.pop_lazy(options, :name, fn -> generate_name(field, type, options) end),
else: {generate_name(field, type, options), options}
end

@spec module_ast(module(), boolean(), shape(), map(), definitions()) :: Macro.output()
defp module_ast(module, nested?, fields, values, %{funs: funs, defs: defs}) do
{funs, defs} =
Enum.reduce(fields, {funs, defs}, fn
{field, {:type, {type, options}}}, {funs, defs} when type in @metas ->
{name, opts} = get_name_opts(field, type, options)

type =
if Code.ensure_loaded?(name),
do: name,
else: Scaffold.create(type, name, opts)

{[{:coerce, field}, {:validate, field} | funs],
[coercer_and_validator(field, type) | defs]}

{field, {:type, type}}, {funs, defs} ->
{[{:coerce, field}, {:validate, field} | funs],
[coercer_and_validator(field, type) | defs]}

_, acc ->
acc
end)

{coercions, validations} = coercions_and_validations(funs)

calculated =
Expand Down
34 changes: 34 additions & 0 deletions lib/estructura/nested/type.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule Estructura.Nested.Type do
@moduledoc """
The type to be used for coercing, validating, and generation
of the implementation’s instances.
"""

@doc "The generator for the type"
@callback generate() :: StreamData.t(any())

@doc "The generator for the type accepting options"
@callback generate(keyword()) :: StreamData.t(any())

@doc "Coerces the value coming from outside"
@callback coerce(term()) :: {:ok, term()} | {:error, any()}

@doc "Validates the value as being correct"
@callback validate(term()) :: {:ok, term()} | {:error, any()}

defmodule Scaffold do
@moduledoc false

@callback type_module_ast(name :: module(), opts :: keyword()) :: Macro.t()

@spec create(module(), module(), keyword()) :: module() | false
def create(scaffold, name, options) do
with true <- is_atom(scaffold),
true <- Code.ensure_loaded?(scaffold),
true <- function_exported?(scaffold, :type_module_ast, 2),
{:module, module, _bytecode, _} <-
scaffold.type_module_ast(name, options),
do: module
end
end
end
16 changes: 16 additions & 0 deletions lib/estructura/nested/type/date.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Estructura.Nested.Type.Date do
@moduledoc """
`Estructura` type for `Date`
"""
@behaviour Estructura.Nested.Type

@impl true
def generate(opts \\ []), do: Estructura.StreamData.date(opts)

@impl true
defdelegate coerce(term), to: Estructura.Coercers.Date

@impl true
def validate(%Date{} = term), do: {:ok, term}
def validate(other), do: {:error, "Expected date, got: " <> inspect(other)}
end
16 changes: 16 additions & 0 deletions lib/estructura/nested/type/date_time.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Estructura.Nested.Type.DateTime do
@moduledoc """
`Estructura` type for `Date`
"""
@behaviour Estructura.Nested.Type

@impl true
def generate(opts \\ []), do: Estructura.StreamData.datetime(opts)

@impl true
defdelegate coerce(term), to: Estructura.Coercers.DateTime

@impl true
def validate(%DateTime{} = term), do: {:ok, term}
def validate(other), do: {:error, "Expected date, got: " <> inspect(other)}
end
Loading

0 comments on commit fff43b7

Please sign in to comment.