api-surface #1
4 changed files with 358 additions and 0 deletions
feat(struct): discord_struct DSL — defstruct, @type t, field table generation
commit
1bf9b84518
203
lib/dexcord/struct.ex
Normal file
203
lib/dexcord/struct.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
defmodule Dexcord.Struct do
|
||||
@moduledoc """
|
||||
A DSL for declaring Discord object shapes once.
|
||||
|
||||
One `field` declaration per field generates, at compile time, the
|
||||
`defstruct`, `@type t`, `from_map/1` (string-keyed wire map -> struct,
|
||||
minting zero atoms), `to_map/1` (struct -> JSON-ready map), and
|
||||
`merge_map/2` (partial-update semantics for the cache).
|
||||
|
||||
defmodule Dexcord.Example do
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :name, :string
|
||||
field :roles, {:list, :snowflake}, default: []
|
||||
end
|
||||
end
|
||||
|
||||
Field types: `:snowflake`, `:string`, `:integer`, `:boolean`, `:datetime`,
|
||||
`{:struct, Module}`, `{:list, type}`, `{:enum, Module}`, `{:flags, Module}`,
|
||||
`:raw` (kept verbatim). Modifiers: `default:`, `tristate: true`
|
||||
(`:absent | nil | value`), `presence: true` (key-presence boolean).
|
||||
|
||||
`hydrate slot, from: :id_field, type: Module` declares a cache-fillable
|
||||
slot (always `nil` after decode; populated only by `Dexcord.Cache.fill/1`).
|
||||
"""
|
||||
|
||||
@scalar_types [:snowflake, :string, :integer, :boolean, :datetime, :raw]
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Dexcord.Struct, only: [discord_struct: 1]
|
||||
Module.register_attribute(__MODULE__, :dexcord_fields, accumulate: true)
|
||||
Module.register_attribute(__MODULE__, :dexcord_hydrates, accumulate: true)
|
||||
@before_compile Dexcord.Struct
|
||||
end
|
||||
end
|
||||
|
||||
defmacro discord_struct(do: block) do
|
||||
quote do
|
||||
try do
|
||||
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2]
|
||||
unquote(block)
|
||||
after
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro field(name, type, opts \\ []) do
|
||||
quote do
|
||||
@dexcord_fields {unquote(name), unquote(Macro.escape(type)), unquote(opts)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro hydrate(name, opts) do
|
||||
quote do
|
||||
@dexcord_hydrates {unquote(name), unquote(Macro.escape(opts))}
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro __before_compile__(env) do
|
||||
module = env.module
|
||||
|
||||
fields =
|
||||
(Module.get_attribute(module, :dexcord_fields) || [])
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn {name, type_ast, opts} -> build_field(name, type_ast, opts, env) end)
|
||||
|
||||
hydrates =
|
||||
(Module.get_attribute(module, :dexcord_hydrates) || [])
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn {name, opts} -> build_hydrate(name, opts, env) end)
|
||||
|
||||
struct_defaults =
|
||||
Enum.map(fields, fn f -> {f.name, f.default} end) ++
|
||||
Enum.map(hydrates, fn h -> {h.name, nil} end)
|
||||
|
||||
field_type_pairs =
|
||||
Enum.map(fields, fn f -> {f.name, field_typespec(f, module)} end) ++
|
||||
Enum.map(hydrates, fn h -> {h.name, hydrate_typespec(h, module)} end)
|
||||
|
||||
type_ast = {:%, [], [{:__MODULE__, [], Elixir}, {:%{}, [], field_type_pairs}]}
|
||||
|
||||
quote do
|
||||
defstruct unquote(Macro.escape(struct_defaults))
|
||||
|
||||
@type t :: unquote(type_ast)
|
||||
|
||||
@doc false
|
||||
def __fields__, do: unquote(Macro.escape(fields))
|
||||
|
||||
@doc false
|
||||
def __hydrations__, do: unquote(Macro.escape(hydrates))
|
||||
|
||||
@doc "Decodes a string-keyed wire map into `t()`. Non-maps decode to `nil`."
|
||||
@spec from_map(term()) :: t() | nil
|
||||
def from_map(map), do: Dexcord.Struct.Codec.from_map(__MODULE__, map)
|
||||
|
||||
@doc "Encodes to a JSON-ready string-keyed map. `nil`/`:absent` fields are omitted."
|
||||
@spec to_map(t()) :: %{optional(String.t()) => term()}
|
||||
def to_map(%__MODULE__{} = struct), do: Dexcord.Struct.Codec.to_map(struct)
|
||||
|
||||
@doc "Applies a partial wire map, updating only keys present in it."
|
||||
@spec merge_map(t(), map()) :: t()
|
||||
def merge_map(%__MODULE__{} = struct, map),
|
||||
do: Dexcord.Struct.Codec.merge_map(struct, map)
|
||||
end
|
||||
end
|
||||
|
||||
# -- compile-time helpers (run in the macro, not in generated code) --
|
||||
|
||||
@doc false
|
||||
def build_field(name, type_ast, opts, env) when type_ast == :presence,
|
||||
do: build_field(name, :boolean, Keyword.put(opts, :presence, true), env)
|
||||
|
||||
def build_field(name, type_ast, opts, env) do
|
||||
type = resolve_type(type_ast, env)
|
||||
validate_type!(type, name, env.module)
|
||||
tristate = Keyword.get(opts, :tristate, false)
|
||||
presence = Keyword.get(opts, :presence, false)
|
||||
|
||||
if tristate and presence do
|
||||
raise ArgumentError,
|
||||
"#{inspect(env.module)}.#{name}: tristate and presence are mutually exclusive"
|
||||
end
|
||||
|
||||
default =
|
||||
cond do
|
||||
Keyword.has_key?(opts, :default) -> Keyword.fetch!(opts, :default)
|
||||
tristate -> :absent
|
||||
presence -> false
|
||||
true -> nil
|
||||
end
|
||||
|
||||
%{
|
||||
name: name,
|
||||
key: Atom.to_string(name),
|
||||
type: type,
|
||||
default: default,
|
||||
tristate: tristate,
|
||||
presence: presence
|
||||
}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def build_hydrate(name, opts, env) do
|
||||
%{
|
||||
name: name,
|
||||
from: Keyword.fetch!(opts, :from),
|
||||
type: resolve_type(Keyword.fetch!(opts, :type), env)
|
||||
}
|
||||
end
|
||||
|
||||
defp resolve_type({:struct, mod_ast}, env), do: {:struct, resolve_type(mod_ast, env)}
|
||||
defp resolve_type({:list, inner}, env), do: {:list, resolve_type(inner, env)}
|
||||
defp resolve_type({:enum, mod_ast}, env), do: {:enum, resolve_type(mod_ast, env)}
|
||||
defp resolve_type({:flags, mod_ast}, env), do: {:flags, resolve_type(mod_ast, env)}
|
||||
defp resolve_type(atom, _env) when is_atom(atom), do: atom
|
||||
defp resolve_type(ast, env), do: Macro.expand(ast, env)
|
||||
|
||||
defp validate_type!(type, _name, _module) when type in @scalar_types, do: :ok
|
||||
defp validate_type!({:struct, mod}, _name, _module) when is_atom(mod), do: :ok
|
||||
defp validate_type!({:enum, mod}, _name, _module) when is_atom(mod), do: :ok
|
||||
defp validate_type!({:flags, mod}, _name, _module) when is_atom(mod), do: :ok
|
||||
defp validate_type!({:list, inner}, name, module), do: validate_type!(inner, name, module)
|
||||
|
||||
defp validate_type!(other, name, module) do
|
||||
raise ArgumentError,
|
||||
"#{inspect(module)}.#{name}: unknown field type #{inspect(other)}"
|
||||
end
|
||||
|
||||
# -- typespec construction --
|
||||
|
||||
defp field_typespec(%{presence: true}, _module), do: quote(do: boolean())
|
||||
|
||||
defp field_typespec(%{tristate: true} = f, module),
|
||||
do: quote(do: :absent | nil | unquote(base_typespec(f.type, module)))
|
||||
|
||||
defp field_typespec(f, module),
|
||||
do: quote(do: unquote(base_typespec(f.type, module)) | nil)
|
||||
|
||||
defp hydrate_typespec(%{type: :channel}, _module), do: quote(do: struct() | nil)
|
||||
|
||||
defp hydrate_typespec(%{type: mod}, module),
|
||||
do: quote(do: unquote(base_typespec({:struct, mod}, module)) | nil)
|
||||
|
||||
defp base_typespec(:snowflake, _m), do: quote(do: Dexcord.Snowflake.t())
|
||||
defp base_typespec(:string, _m), do: quote(do: String.t())
|
||||
defp base_typespec(:integer, _m), do: quote(do: integer())
|
||||
defp base_typespec(:boolean, _m), do: quote(do: boolean())
|
||||
defp base_typespec(:datetime, _m), do: quote(do: DateTime.t())
|
||||
defp base_typespec(:raw, _m), do: quote(do: term())
|
||||
defp base_typespec({:struct, mod}, mod), do: quote(do: t())
|
||||
defp base_typespec({:struct, mod}, _m), do: quote(do: unquote(mod).t())
|
||||
defp base_typespec({:list, inner}, m), do: quote(do: [unquote(base_typespec(inner, m))])
|
||||
defp base_typespec({:enum, mod}, _m), do: quote(do: unquote(mod).t())
|
||||
defp base_typespec({:flags, _mod}, _m), do: quote(do: non_neg_integer())
|
||||
end
|
||||
7
lib/dexcord/struct/codec.ex
Normal file
7
lib/dexcord/struct/codec.ex
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
defmodule Dexcord.Struct.Codec do
|
||||
@moduledoc false
|
||||
|
||||
def from_map(_module, _map), do: raise("not implemented until Task 5")
|
||||
def to_map(_struct), do: raise("not implemented until Task 5")
|
||||
def merge_map(_struct, _map), do: raise("not implemented until Task 5")
|
||||
end
|
||||
115
test/dexcord/struct_test.exs
Normal file
115
test/dexcord/struct_test.exs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
defmodule Dexcord.StructTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Test.Widget
|
||||
|
||||
describe "compile shape (Task 4)" do
|
||||
test "struct has all declared keys with correct defaults" do
|
||||
w = %Widget{}
|
||||
assert w.id == nil
|
||||
assert w.name == nil
|
||||
assert w.count == nil
|
||||
assert w.enabled == nil
|
||||
assert w.created_at == nil
|
||||
assert w.gadget == nil
|
||||
assert w.gadgets == []
|
||||
assert w.tag_ids == []
|
||||
assert w.color == nil
|
||||
assert w.flags == nil
|
||||
assert w.parent == :absent
|
||||
assert w.premium == false
|
||||
assert w.note == "n/a"
|
||||
assert w.extra == nil
|
||||
assert w.gadget_full == nil
|
||||
end
|
||||
|
||||
test "__fields__/0 returns the table in declaration order with resolved types" do
|
||||
fields = Widget.__fields__()
|
||||
names = Enum.map(fields, & &1.name)
|
||||
|
||||
assert names == [
|
||||
:id,
|
||||
:name,
|
||||
:count,
|
||||
:enabled,
|
||||
:created_at,
|
||||
:gadget,
|
||||
:gadgets,
|
||||
:tag_ids,
|
||||
:color,
|
||||
:flags,
|
||||
:parent,
|
||||
:premium,
|
||||
:note,
|
||||
:extra
|
||||
]
|
||||
|
||||
by_name = Map.new(fields, &{&1.name, &1})
|
||||
|
||||
# nested struct type resolved to a real atom module
|
||||
assert by_name[:gadget].type == {:struct, Dexcord.Test.Gadget}
|
||||
assert by_name[:gadgets].type == {:list, {:struct, Dexcord.Test.Gadget}}
|
||||
assert by_name[:tag_ids].type == {:list, :snowflake}
|
||||
assert by_name[:color].type == {:enum, Dexcord.Test.Color}
|
||||
assert by_name[:flags].type == {:flags, Dexcord.Test.TestFlags}
|
||||
|
||||
# self-referencing struct resolves __MODULE__ to the module atom
|
||||
assert by_name[:parent].type == {:struct, Dexcord.Test.Widget}
|
||||
assert by_name[:parent].tristate == true
|
||||
|
||||
# presence sugar
|
||||
assert by_name[:premium].presence == true
|
||||
assert by_name[:premium].type == :boolean
|
||||
|
||||
# keys are strings
|
||||
assert by_name[:id].key == "id"
|
||||
assert by_name[:created_at].key == "created_at"
|
||||
|
||||
# defaults
|
||||
assert by_name[:gadgets].default == []
|
||||
assert by_name[:tag_ids].default == []
|
||||
assert by_name[:parent].default == :absent
|
||||
assert by_name[:premium].default == false
|
||||
assert by_name[:note].default == "n/a"
|
||||
end
|
||||
|
||||
test "__hydrations__/0 returns the hydrate table" do
|
||||
assert Widget.__hydrations__() == [
|
||||
%{name: :gadget_full, from: :id, type: Dexcord.Test.Gadget}
|
||||
]
|
||||
end
|
||||
|
||||
test "the module generates a :t type" do
|
||||
{:ok, types} = Code.Typespec.fetch_types(Widget)
|
||||
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
|
||||
end
|
||||
|
||||
test "an unknown field type raises ArgumentError at compile time" do
|
||||
assert_raise ArgumentError, ~r/unknown field type/, fn ->
|
||||
Code.compile_string("""
|
||||
defmodule Dexcord.Test.BogusCompile do
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :x, :bogus_type
|
||||
end
|
||||
end
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
||||
test "tristate and presence are mutually exclusive at compile time" do
|
||||
assert_raise ArgumentError, ~r/mutually exclusive/, fn ->
|
||||
Code.compile_string("""
|
||||
defmodule Dexcord.Test.BadModifiers do
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :x, :string, tristate: true, presence: true
|
||||
end
|
||||
end
|
||||
""")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -7,3 +7,36 @@ defmodule Dexcord.Test.TestFlags do
|
|||
@moduledoc false
|
||||
use Dexcord.Flags, alpha: 0, beta: 1, gamma: 4
|
||||
end
|
||||
|
||||
defmodule Dexcord.Test.Gadget do
|
||||
@moduledoc false
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :name, :string
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Dexcord.Test.Widget do
|
||||
@moduledoc false
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :name, :string
|
||||
field :count, :integer
|
||||
field :enabled, :boolean
|
||||
field :created_at, :datetime
|
||||
field :gadget, {:struct, Dexcord.Test.Gadget}
|
||||
field :gadgets, {:list, {:struct, Dexcord.Test.Gadget}}, default: []
|
||||
field :tag_ids, {:list, :snowflake}, default: []
|
||||
field :color, {:enum, Dexcord.Test.Color}
|
||||
field :flags, {:flags, Dexcord.Test.TestFlags}
|
||||
field :parent, {:struct, __MODULE__}, tristate: true
|
||||
field :premium, :presence
|
||||
field :note, :string, default: "n/a"
|
||||
field :extra, :raw
|
||||
hydrate :gadget_full, from: :id, type: Dexcord.Test.Gadget
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue