api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
4 changed files with 158 additions and 13 deletions
Showing only changes of commit ae1f312e3b - Show all commits

feat(struct): defoverridable generated fns + {:id_map, t} dictionary type

Luna 2026-07-05 04:24:02 -03:00

View file

@ -44,6 +44,27 @@ defmodule Dexcord.Struct do
Module.register_attribute(__MODULE__, :dexcord_fields, accumulate: true)
Module.register_attribute(__MODULE__, :dexcord_hydrates, accumulate: true)
@before_compile Dexcord.Struct
# The generated decode/encode/merge entrypoints are defined here — before
# the module body — and marked `defoverridable`, so a model may define its
# own clause in the body and post-process the generated result via `super/1`
# (e.g. `Dexcord.Interaction`'s polymorphic `data` decode). They take plain
# arguments and delegate to the Codec, which does the struct matching at
# runtime; the `defstruct` they'd otherwise pattern-match is only injected
# later, by `@before_compile`.
@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(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(struct, map), do: Dexcord.Struct.Codec.merge_map(struct, map)
defoverridable from_map: 1, to_map: 1, merge_map: 2
end
end
@ -124,19 +145,6 @@ defmodule Dexcord.Struct do
@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
@ -189,6 +197,7 @@ defmodule Dexcord.Struct do
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({:id_map, inner}, env), do: {:id_map, resolve_type(inner, env)}
defp resolve_type(atom, _env) when is_atom(atom), do: atom
defp resolve_type(ast, env), do: Macro.expand(ast, env)
@ -197,6 +206,7 @@ defmodule Dexcord.Struct do
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!({:id_map, inner}, name, module), do: validate_type!(inner, name, module)
defp validate_type!(other, name, module) do
raise ArgumentError,
@ -228,6 +238,10 @@ defmodule Dexcord.Struct do
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({:id_map, inner}, m),
do: quote(do: %{optional(Dexcord.Snowflake.t()) => 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

View file

@ -68,6 +68,23 @@ defmodule Dexcord.Struct.Codec do
end
end
# Discord's `%{"snowflake_string" => object}` dictionaries (interaction
# resolved data): snowflake-castable keys become integers, everything else
# keeps its string key; values decode leniently through the inner type.
defp decode_value({:id_map, inner}, v, _d) when is_map(v) and not is_struct(v) do
Map.new(v, fn {k, val} ->
key =
case Dexcord.Snowflake.cast(k) do
{:ok, int} -> int
:error -> k
end
{key, decode_element(inner, val)}
end)
end
defp decode_value({:id_map, _inner}, _v, d), do: d
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
# the raw wire value passes through untouched.
defp decode_value(_type, v, _d), do: v
@ -110,6 +127,14 @@ defmodule Dexcord.Struct.Codec do
defp encode_value({:list, inner}, v) when is_list(v),
do: Enum.map(v, &encode_element(inner, &1))
# id_map: re-stringify integer snowflake keys, encode values through the inner
# type; non-integer keys (junk we kept verbatim on decode) pass through.
defp encode_value({:id_map, inner}, v) when is_map(v) and not is_struct(v) do
Map.new(v, fn {k, val} ->
{if(is_integer(k), do: Integer.to_string(k), else: k), encode_element(inner, val)}
end)
end
defp encode_value({:enum, mod}, v) when is_atom(v) and not is_nil(v), do: mod.encode(v)
defp encode_value({:enum, _mod}, {:unknown, n}) when is_integer(n), do: n
# raw passthrough: junk scalars that decode kept raw re-encode raw; a raw

View file

@ -1,8 +1,10 @@
defmodule Dexcord.StructTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Contraption
alias Dexcord.Test.Doohickey
alias Dexcord.Test.Gadget
alias Dexcord.Test.Overridden
alias Dexcord.Test.Widget
# A representative full wire map (string-keyed), reused across decode/encode tests.
@ -366,6 +368,73 @@ defmodule Dexcord.StructTest do
end
end
describe "{:id_map, type} dictionary field (Phase 3, Task 1)" do
test "resolves the id_map type and its default on the field table" do
by_name = Map.new(Contraption.__fields__(), &{&1.name, &1})
assert by_name[:things].type == {:id_map, {:struct, Dexcord.Test.Gadget}}
assert by_name[:things].default == %{}
end
test "decodes a snowflake-keyed dictionary to integer keys and typed values" do
c = Contraption.from_map(%{"things" => %{"111" => %{"id" => "1", "name" => "a"}}})
assert c.things == %{111 => %Gadget{id: 1, name: "a"}}
end
test "a non-snowflake key stays a string key, value still decodes" do
c = Contraption.from_map(%{"things" => %{"abc" => %{"id" => "1", "name" => "a"}}})
assert c.things == %{"abc" => %Gadget{id: 1, name: "a"}}
end
test "a junk (non-map) value decodes leniently to nil for a struct inner" do
c = Contraption.from_map(%{"things" => %{"111" => "not-a-map"}})
assert c.things == %{111 => nil}
end
test "a non-map id_map value falls to the declared default" do
assert Contraption.from_map(%{"things" => [1, 2]}).things == %{}
assert Contraption.from_map(%{"things" => "junk"}).things == %{}
end
test "absent id_map key uses the declared default" do
assert Contraption.from_map(%{}).things == %{}
end
test "re-encode stringifies integer keys and encodes struct values" do
c = Contraption.from_map(%{"things" => %{"111" => %{"id" => "1", "name" => "a"}}})
m = Contraption.to_map(c)
assert m["things"] == %{"111" => %{"id" => "1", "name" => "a"}}
end
test "re-encode keeps a non-integer key verbatim" do
c = %Contraption{things: %{"abc" => %Gadget{id: 1, name: "a"}}}
m = Contraption.to_map(c)
assert m["things"] == %{"abc" => %{"id" => "1", "name" => "a"}}
end
test "id_map field participates in a valid :t typespec" do
{:ok, types} = Code.Typespec.fetch_types(Contraption)
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
end
end
describe "overridable generated functions via super/1 (Phase 3, Task 1)" do
test "an override post-processes the DSL-generated from_map decode" do
o = Overridden.from_map(%{"id" => "5", "name" => "luna"})
assert o.id == 5
assert o.name == "LUNA"
end
test "the override still returns nil for non-map input (super passes through)" do
assert Overridden.from_map("junk") == nil
end
test "generated to_map and merge_map stay callable on the overriding module" do
o = Overridden.from_map(%{"id" => "5", "name" => "x"})
assert Overridden.to_map(o)["name"] == "X"
assert Overridden.merge_map(o, %{"id" => "9"}).id == 9
end
end
describe "merge_map partial update (Task 6)" do
test "api-surface.AC2.8: updates only keys present in the partial map" do
w = Widget.from_map(full_wire_map())

View file

@ -68,3 +68,40 @@ defmodule Dexcord.Test.Doohickey do
field :tail, :string
end
end
# Exercises Phase 3 Task 1: the `{:id_map, type}` snowflake-keyed dictionary
# field type (Discord's resolved-data shape).
defmodule Dexcord.Test.Contraption do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :things, {:id_map, {:struct, Dexcord.Test.Gadget}}, default: %{}
end
end
# Exercises Phase 3 Task 1: overriding a generated function and calling
# `super/1` to post-process the DSL-generated decode.
defmodule Dexcord.Test.Overridden do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :name, :string
end
# NOTE: the override must NOT pattern-match `%__MODULE__{}` — `defstruct` is
# injected by `@before_compile` and is not yet defined where the body compiles.
# Match the struct as a plain map (a struct IS a map) guarded by `is_struct/2`.
def from_map(map) do
case super(map) do
%{name: name} = struct when is_struct(struct, __MODULE__) and is_binary(name) ->
%{struct | name: String.upcase(name)}
other ->
other
end
end
end