Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better infra for Types #15

Merged
merged 5 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading