api-surface #1
5 changed files with 141 additions and 10 deletions
feat(struct): :number type, string-wire flags, wire_string opt, include_fields
commit
202be23ba0
|
|
@ -1,5 +1,11 @@
|
|||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
|
||||
locals_without_parens: [field: 2, field: 3, hydrate: 2, discord_struct: 1]
|
||||
locals_without_parens: [
|
||||
field: 2,
|
||||
field: 3,
|
||||
hydrate: 2,
|
||||
discord_struct: 1,
|
||||
include_fields: 1
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -17,16 +17,26 @@ defmodule Dexcord.Struct do
|
|||
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).
|
||||
Field types: `:snowflake`, `:string`, `:integer`, `:number` (int or float),
|
||||
`: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), `wire_string: true` (re-encode an integer value as a
|
||||
decimal string, for bitfields that exceed 2^53 like permissions).
|
||||
|
||||
`{:flags, Module}` decodes both integers and decimal strings (Discord sends
|
||||
permission bitfields as strings); pair it with `wire_string: true` to
|
||||
round-trip back to a string.
|
||||
|
||||
`include_fields Provider` pulls a shared field group from a plain-data module
|
||||
exporting `__included_fields__/0` (a list of `{name, type, opts}`); the fields
|
||||
land in declaration position, indistinguishable from hand-written `field`s.
|
||||
|
||||
`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]
|
||||
@scalar_types [:snowflake, :string, :integer, :number, :boolean, :datetime, :raw]
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
|
|
@ -40,7 +50,7 @@ defmodule Dexcord.Struct do
|
|||
defmacro discord_struct(do: block) do
|
||||
quote do
|
||||
try do
|
||||
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2]
|
||||
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2, include_fields: 1]
|
||||
unquote(block)
|
||||
after
|
||||
:ok
|
||||
|
|
@ -55,6 +65,15 @@ defmodule Dexcord.Struct do
|
|||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro include_fields(mod) do
|
||||
quote do
|
||||
Enum.each(unquote(mod).__included_fields__(), fn {name, type, opts} ->
|
||||
Module.put_attribute(__MODULE__, :dexcord_fields, {name, type, opts})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
defmacro hydrate(name, opts) do
|
||||
quote do
|
||||
|
|
@ -152,7 +171,8 @@ defmodule Dexcord.Struct do
|
|||
type: type,
|
||||
default: default,
|
||||
tristate: tristate,
|
||||
presence: presence
|
||||
presence: presence,
|
||||
wire_string: Keyword.get(opts, :wire_string, false)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -201,6 +221,7 @@ defmodule Dexcord.Struct do
|
|||
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(:number, _m), do: quote(do: number())
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -59,6 +59,15 @@ defmodule Dexcord.Struct.Codec do
|
|||
|
||||
defp decode_value({:flags, _mod}, v, _d) when is_integer(v), do: v
|
||||
|
||||
# Permission-style bitfields arrive as decimal strings (they exceed 2^53).
|
||||
# Parse a clean decimal string to an integer; anything else stays raw.
|
||||
defp decode_value({:flags, _mod}, v, _d) when is_binary(v) do
|
||||
case Integer.parse(v) do
|
||||
{int, ""} -> int
|
||||
_ -> v
|
||||
end
|
||||
end
|
||||
|
||||
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
|
||||
# the raw wire value passes through untouched.
|
||||
defp decode_value(_type, v, _d), do: v
|
||||
|
|
@ -80,7 +89,16 @@ defmodule Dexcord.Struct.Codec do
|
|||
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_field(f, value) do
|
||||
encoded = encode_value(f.type, value)
|
||||
|
||||
if f.wire_string and is_integer(encoded) do
|
||||
{:ok, Integer.to_string(encoded)}
|
||||
else
|
||||
{:ok, encoded}
|
||||
end
|
||||
end
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Dexcord.StructTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Test.Doohickey
|
||||
alias Dexcord.Test.Gadget
|
||||
alias Dexcord.Test.Widget
|
||||
|
||||
|
|
@ -198,7 +199,9 @@ defmodule Dexcord.StructTest do
|
|||
assert Widget.from_map(%{"created_at" => "not-a-date"}).created_at == "not-a-date"
|
||||
assert Widget.from_map(%{"id" => "12x"}).id == "12x"
|
||||
assert Widget.from_map(%{"color" => "red"}).color == "red"
|
||||
assert Widget.from_map(%{"flags" => "3"}).flags == "3"
|
||||
# a decimal-string flags value now decodes to an integer (permission
|
||||
# bitfields arrive as strings); a non-numeric junk string stays raw.
|
||||
assert Widget.from_map(%{"flags" => "3x"}).flags == "3x"
|
||||
|
||||
# whole-value junk -> nil
|
||||
assert Widget.from_map("hello") == nil
|
||||
|
|
@ -308,6 +311,61 @@ defmodule Dexcord.StructTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "DSL extensions (Phase 2, Task 1)" do
|
||||
test ":number decodes float and integer verbatim, junk stays raw" do
|
||||
assert Doohickey.from_map(%{"ratio" => 3.5}).ratio == 3.5
|
||||
assert Doohickey.from_map(%{"ratio" => 3}).ratio == 3
|
||||
assert Doohickey.from_map(%{"ratio" => "not-a-number"}).ratio == "not-a-number"
|
||||
end
|
||||
|
||||
test ":number resolves to the :number field type" do
|
||||
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
|
||||
assert by_name[:ratio].type == :number
|
||||
end
|
||||
|
||||
test "string-wire flags decode: decimal string -> integer, junk stays raw, int passes through" do
|
||||
assert Doohickey.from_map(%{"bits" => "17"}).bits == 17
|
||||
assert Doohickey.from_map(%{"bits" => "12x"}).bits == "12x"
|
||||
assert Doohickey.from_map(%{"bits" => 5}).bits == 5
|
||||
end
|
||||
|
||||
test "wire_string: true encodes integer flags to a decimal string" do
|
||||
m = Doohickey.to_map(%Doohickey{perms: 17})
|
||||
assert m["perms"] == "17"
|
||||
end
|
||||
|
||||
test "without wire_string a flags field encodes as the raw integer" do
|
||||
m = Doohickey.to_map(%Doohickey{bits: 17})
|
||||
assert m["bits"] == 17
|
||||
end
|
||||
|
||||
test "wire_string is recorded on the field table" do
|
||||
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
|
||||
assert by_name[:perms].wire_string == true
|
||||
assert by_name[:bits].wire_string == false
|
||||
end
|
||||
|
||||
test "include_fields injects the shared group in declaration position" do
|
||||
names = Enum.map(Doohickey.__fields__(), & &1.name)
|
||||
assert names == [:id, :ratio, :perms, :bits, :shared_a, :shared_b, :tail]
|
||||
end
|
||||
|
||||
test "included fields carry their declared types and defaults" do
|
||||
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
|
||||
assert by_name[:shared_a].type == :string
|
||||
assert by_name[:shared_b].type == {:list, :snowflake}
|
||||
assert by_name[:shared_b].default == []
|
||||
assert %Doohickey{}.shared_b == []
|
||||
end
|
||||
|
||||
test "included fields decode identically to hand-declared fields" do
|
||||
d = Doohickey.from_map(%{"shared_a" => "x", "shared_b" => ["1", 2], "tail" => "end"})
|
||||
assert d.shared_a == "x"
|
||||
assert d.shared_b == [1, 2]
|
||||
assert d.tail == "end"
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -40,3 +40,31 @@ defmodule Dexcord.Test.Widget do
|
|||
hydrate :gadget_full, from: :id, type: Dexcord.Test.Gadget
|
||||
end
|
||||
end
|
||||
|
||||
# A plain-data field-group provider for exercising `include_fields/1`.
|
||||
defmodule Dexcord.Test.SharedGroup do
|
||||
@moduledoc false
|
||||
|
||||
def __included_fields__ do
|
||||
[
|
||||
{:shared_a, :string, []},
|
||||
{:shared_b, {:list, :snowflake}, [default: []]}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
# Exercises Task 1's DSL extensions: `:number`, string-wire flags, the
|
||||
# `wire_string:` opt, and `include_fields/1` interleaved with own fields.
|
||||
defmodule Dexcord.Test.Doohickey do
|
||||
@moduledoc false
|
||||
use Dexcord.Struct
|
||||
|
||||
discord_struct do
|
||||
field :id, :snowflake
|
||||
field :ratio, :number
|
||||
field :perms, {:flags, Dexcord.Test.TestFlags}, wire_string: true
|
||||
field :bits, {:flags, Dexcord.Test.TestFlags}
|
||||
include_fields Dexcord.Test.SharedGroup
|
||||
field :tail, :string
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue