api-surface #1

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

feat(struct): to_map encode + merge_map partial-update semantics

Luna 2026-07-05 03:26:01 -03:00

View file

@ -66,6 +66,48 @@ defmodule Dexcord.Struct.Codec do
defp decode_element(_inner, nil), do: nil
defp decode_element(inner, v), do: decode_value(inner, v, nil)
def to_map(_struct), do: raise("not implemented until Task 6")
def merge_map(_struct, _map), do: raise("not implemented until Task 6")
@doc false
def to_map(%module{} = struct) do
Enum.reduce(module.__fields__(), %{}, fn f, acc ->
case encode_field(f, Map.fetch!(struct, f.name)) do
:skip -> acc
{:ok, encoded} -> Map.put(acc, f.key, encoded)
end
end)
end
defp encode_field(%{tristate: true}, :absent), do: :skip
defp encode_field(%{presence: true}, true), do: {:ok, nil}
defp encode_field(%{presence: true}, false), do: :skip
defp encode_field(_f, nil), do: :skip
defp encode_field(f, value), do: {:ok, encode_value(f.type, value)}
defp encode_value(:snowflake, v) when is_integer(v), do: Integer.to_string(v)
defp encode_value(:datetime, %DateTime{} = v), do: DateTime.to_iso8601(v)
# Route through the struct's own to_map/1 (not Codec.to_map directly): generated
# structs delegate straight back here, while hand-written fallback structs
# (UnknownChannel, Component.Unknown — no __fields__/0) provide their own.
defp encode_value({:struct, _mod}, %_{} = v), do: v.__struct__.to_map(v)
defp encode_value({:list, inner}, v) when is_list(v), do: Enum.map(v, &encode_element(inner, &1))
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
# int in an enum/flags field is already wire format.
defp encode_value(_type, v), do: v
defp encode_element(_inner, nil), do: nil
defp encode_element(inner, v), do: encode_value(inner, v)
@doc false
def merge_map(%module{} = struct, map) when is_map(map) and not is_struct(map) do
Enum.reduce(module.__fields__(), struct, fn f, acc ->
if Map.has_key?(map, f.key) do
Map.put(acc, f.name, decode_field(f, map))
else
acc
end
end)
end
def merge_map(struct, _non_map), do: struct
end

View file

@ -236,4 +236,123 @@ defmodule Dexcord.StructTest do
assert w.gadgets == [%Gadget{id: 1, name: "a"}, nil]
end
end
describe "to_map encode (Task 6)" do
test "api-surface.AC2.9: value encodings for a decoded struct" do
w = Widget.from_map(full_wire_map())
m = Widget.to_map(w)
# snowflake integer -> decimal string
assert m["id"] == "123"
# DateTime -> ISO8601
assert m["created_at"] == "2026-07-04T12:00:00Z"
# nested struct/list encode as plain maps
assert m["gadget"] == %{"id" => "9", "name" => "g"}
assert m["gadgets"] == [%{"id" => "1", "name" => "a"}, %{"id" => "2", "name" => "b"}]
# scalars pass through
assert m["name"] == "w"
assert m["count"] == 7
assert m["enabled"] == true
# empty-list default still encodes (real information)
assert m["tag_ids"] == ["111", "222"]
# nil fields are omitted
refute Map.has_key?(m, "color")
refute Map.has_key?(m, "flags")
refute Map.has_key?(m, "extra")
# :absent tristate is omitted (AC2.6 encode direction)
refute Map.has_key?(m, "parent")
# presence false -> omitted
refute Map.has_key?(m, "premium")
end
test "enum encodes losslessly for known and unknown values" do
known = Widget.to_map(%Widget{color: :blue})
assert known["color"] == 4
unknown = Widget.to_map(%Widget{color: {:unknown, 2}})
assert unknown["color"] == 2
end
test "flags raw int encodes back as the raw int" do
m = Widget.to_map(%Widget{flags: 129})
assert m["flags"] == 129
end
test "presence true encodes as present-with-null" do
m = Widget.to_map(%Widget{premium: true})
assert Map.has_key?(m, "premium")
assert m["premium"] == nil
end
test "full round-trip is stable for a junk-free map" do
first = Widget.from_map(full_wire_map())
round_tripped = first |> Widget.to_map() |> Widget.from_map()
assert round_tripped == first
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())
merged = Widget.merge_map(w, %{"name" => "renamed"})
assert merged.name == "renamed"
# every other field is untouched
assert merged.id == w.id
assert merged.count == w.count
assert merged.enabled == w.enabled
assert merged.created_at == w.created_at
assert merged.gadget == w.gadget
assert merged.gadgets == w.gadgets
assert merged.tag_ids == w.tag_ids
assert merged.parent == :absent
assert merged.premium == w.premium
assert merged.gadget_full == w.gadget_full
end
test "present-as-null sets nil, absent key leaves value alone" do
w = Widget.from_map(full_wire_map())
assert w.parent == :absent
# present with null -> nil
assert Widget.merge_map(w, %{"parent" => nil}).parent == nil
# absent key leaves the existing :absent alone (not reset)
assert Widget.merge_map(w, %{}).parent == :absent
# count present-as-null -> nil, and only count changes
merged = Widget.merge_map(w, %{"count" => nil})
assert merged.count == nil
assert merged.name == w.name
end
test "non-map partial returns the struct unchanged" do
w = Widget.from_map(full_wire_map())
assert Widget.merge_map(w, "junk") == w
end
test "unknown key in partial changes nothing and mints no atom" do
w = Widget.from_map(full_wire_map())
merged = Widget.merge_map(w, %{"totally_unknown_merge_key_xy99" => 1})
assert merged == w
assert_raise ArgumentError, fn ->
String.to_existing_atom("totally_unknown_merge_key_xy99")
end
end
test "merge decode parity: nested struct decodes like from_map" do
w = Widget.from_map(full_wire_map())
merged = Widget.merge_map(w, %{"gadget" => %{"id" => 5, "name" => "x"}})
assert merged.gadget == %Gadget{id: 5, name: "x"}
end
end
end