Skip to content

Commit 752b4f3

Browse files
committed
feat: support optional Encoder protocol for structs
Structs (maps with an atom __struct__ key) are rejected by the NIF encoder with {:error, :unhandled_struct}; the Elixir layer then runs the term through the Torque.Encoder protocol and retries once. - NIF detects structs while encoding maps (zero cost when absent) - Torque.Encoder protocol with Any fallback (opt-in per struct type) - normalize/1 recursively encodes protocol output - consolidation deferred to the host project so it can implement the protocol for its own structs (e.g. Decimal) A struct without an implementation still fails with :unhandled_struct — never silently dropped.
1 parent 46d19a9 commit 752b4f3

5 files changed

Lines changed: 329 additions & 16 deletions

File tree

lib/torque.ex

Lines changed: 155 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -146,18 +146,24 @@ defmodule Torque do
146146
{:ok, ~s({"id":"abc"})}
147147
"""
148148
@doc group: :encode
149-
@spec encode(term(), keyword()) :: {:ok, binary()} | {:error, binary() | :nesting_too_deep}
149+
@spec encode(term(), keyword()) ::
150+
{:ok, binary()}
151+
| {:error, binary() | :nesting_too_deep | :unhandled_struct}
150152
def encode(term, opts \\ [])
151153

152154
def encode(term, []) do
153-
Torque.Native.encode(term)
155+
case Torque.Native.encode(term) do
156+
{:error, :unhandled_struct} -> encode_retry(term, false)
157+
other -> other
158+
end
154159
end
155160

156161
def encode(term, opts) do
157-
if Keyword.get(opts, :dirty, false) do
158-
Torque.Native.encode_dirty(term)
159-
else
160-
Torque.Native.encode(term)
162+
dirty = Keyword.get(opts, :dirty, false)
163+
164+
case encode_native(term, dirty) do
165+
{:error, :unhandled_struct} -> encode_retry(term, dirty)
166+
other -> other
161167
end
162168
end
163169

@@ -199,19 +205,23 @@ defmodule Torque do
199205
def encode_to_iodata(term, opts \\ [])
200206

201207
def encode_to_iodata(term, []) do
202-
Torque.Native.encode_iodata(term)
208+
encode_iodata_native(term, false)
203209
catch
204-
:error, value -> raise ArgumentError, "encode error: #{inspect(value)}"
210+
:error, :unhandled_struct ->
211+
term |> normalize() |> encode_iodata_retry(false)
212+
213+
:error, value ->
214+
raise ArgumentError, "encode error: #{inspect(value)}"
205215
end
206216

207217
def encode_to_iodata(term, opts) do
208-
if Keyword.get(opts, :dirty, false) do
209-
Torque.Native.encode_iodata_dirty(term)
210-
else
211-
Torque.Native.encode_iodata(term)
212-
end
218+
encode_iodata_native(term, Keyword.get(opts, :dirty, false))
213219
catch
214-
:error, value -> raise ArgumentError, "encode error: #{inspect(value)}"
220+
:error, :unhandled_struct ->
221+
term |> normalize() |> encode_iodata_retry(Keyword.get(opts, :dirty, false))
222+
223+
:error, value ->
224+
raise ArgumentError, "encode error: #{inspect(value)}"
215225
end
216226

217227
@doc """
@@ -231,6 +241,137 @@ defmodule Torque do
231241
@spec encode_to_iodata!(term(), keyword()) :: binary()
232242
def encode_to_iodata!(term, opts \\ []), do: encode_to_iodata(term, opts)
233243

244+
# --- Encoding protocol ---
245+
246+
defprotocol Encoder do
247+
@moduledoc """
248+
Optional protocol for encoding Elixir structs as JSON.
249+
250+
Torque's NIF encoder rejects structs (maps carrying an atom
251+
`__struct__` key) with `{:error, :unhandled_struct}`. When a struct
252+
implements this protocol, `encode/1` runs it first and encodes the
253+
returned term instead, recursively.
254+
255+
The protocol is deliberately opt-in: a struct without an
256+
implementation is an error, never silently dropped fields.
257+
258+
## Deriving
259+
260+
Structs can derive the implementation, encoding all fields or a
261+
subset via `:only` / `:except`:
262+
263+
@derive {Torque.Encoder, only: [:id, :name]}
264+
defstruct [:id, :name, :secret]
265+
266+
@derive {Torque.Encoder, except: [:secret]}
267+
defstruct [:id, :name, :secret]
268+
269+
> Prefer `:only` to avoid accidentally leaking private information
270+
> when new fields are added later.
271+
272+
## Example
273+
274+
defimpl Torque.Encoder, for: Decimal do
275+
def encode(decimal), do: Decimal.to_string(decimal)
276+
end
277+
278+
Torque.encode!(%{price: Decimal.new("37.50")})
279+
#=> ~s({"price":"37.50"})
280+
"""
281+
282+
@fallback_to_any true
283+
284+
@spec encode(term()) :: term()
285+
def encode(term)
286+
287+
@impl true
288+
defmacro __deriving__(module, opts) do
289+
fields = module |> Macro.struct_info!(__CALLER__) |> Enum.map(& &1.field)
290+
fields = fields_to_encode(fields, opts)
291+
292+
quote do
293+
defimpl Torque.Encoder, for: unquote(module) do
294+
def encode(struct) do
295+
Map.take(struct, unquote(fields))
296+
end
297+
end
298+
end
299+
end
300+
301+
defp fields_to_encode(fields, opts) do
302+
cond do
303+
only = Keyword.get(opts, :only) ->
304+
case only -- fields do
305+
[] ->
306+
only
307+
308+
error_keys ->
309+
raise ArgumentError,
310+
"unknown struct fields #{inspect(error_keys)} specified in :only. " <>
311+
"Expected one of: #{inspect(fields -- [:__struct__])}"
312+
end
313+
314+
except = Keyword.get(opts, :except) ->
315+
case except -- fields do
316+
[] ->
317+
fields -- [:__struct__ | except]
318+
319+
error_keys ->
320+
raise ArgumentError,
321+
"unknown struct fields #{inspect(error_keys)} specified in :except. " <>
322+
"Expected one of: #{inspect(fields -- [:__struct__])}"
323+
end
324+
325+
true ->
326+
fields -- [:__struct__]
327+
end
328+
end
329+
end
330+
331+
# The Any fallback passes structs through untouched, so a struct without
332+
# an explicit implementation still fails in the NIF with
333+
# `:unhandled_struct` after the retry — it is never silently dropped.
334+
defimpl Encoder, for: Any do
335+
def encode(term), do: term
336+
end
337+
338+
defp encode_native(term, dirty) do
339+
if dirty, do: Torque.Native.encode_dirty(term), else: Torque.Native.encode(term)
340+
end
341+
342+
defp encode_retry(term, dirty) do
343+
term |> normalize() |> encode_native(dirty)
344+
end
345+
346+
defp encode_iodata_native(term, true), do: Torque.Native.encode_iodata_dirty(term)
347+
defp encode_iodata_native(term, false), do: Torque.Native.encode_iodata(term)
348+
349+
# Retry path: a struct that still has no protocol implementation fails
350+
# with the same ArgumentError as every other encode error.
351+
defp encode_iodata_retry(term, dirty) do
352+
encode_iodata_native(term, dirty)
353+
catch
354+
:error, value -> raise ArgumentError, "encode error: #{inspect(value)}"
355+
end
356+
357+
defp normalize(%_{} = struct) do
358+
if Encoder.impl_for(struct) != Encoder.Any do
359+
normalize(Encoder.encode(struct))
360+
else
361+
struct
362+
end
363+
end
364+
365+
defp normalize(list) when is_list(list) do
366+
Enum.map(list, &normalize/1)
367+
end
368+
369+
defp normalize(map) when is_map(map) do
370+
Map.new(map, fn {k, v} -> {k, normalize(v)} end)
371+
end
372+
373+
defp normalize(term), do: term
374+
234375
# --- Parse + Get ---
235376

236377
@doc """

mix.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ defmodule Torque.MixProject do
1010
version: @version,
1111
elixir: "~> 1.15",
1212
start_permanent: Mix.env() == :prod,
13+
# Keep the protocol open in test so this repo's own tests can
14+
# implement Torque.Encoder for local structs. Host projects
15+
# consolidate it (with their own implementations) at build time.
16+
consolidate_protocols: Mix.env() != :test,
1317
deps: deps(),
1418
package: package(),
1519
description: "High-performance JSON library for Elixir via Rustler NIFs (sonic-rs)",

native/torque_nif/src/atoms.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,7 @@ rustler::atoms! {
1313
invalid_key,
1414
malformed_proplist,
1515
invalid_utf8,
16+
unhandled_struct,
17+
// struct marker key (atom `__struct__` in Elixir structs)
18+
__struct__ = "__struct__",
1619
}

native/torque_nif/src/encoder.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use crate::nif_util::{make_tuple2, timeslice_percent};
33
use crate::types::MAX_DEPTH;
44
use rustler::sys::{
55
c_int, c_uint, enif_get_atom, enif_get_atom_length, enif_get_double, enif_get_int64,
6-
enif_get_list_cell, enif_get_tuple, enif_get_uint64, enif_inspect_binary, enif_is_empty_list,
7-
ErlNifBinary, ErlNifCharEncoding, ErlNifEnv, ERL_NIF_TERM,
6+
enif_get_list_cell, enif_get_map_value, enif_get_tuple, enif_get_uint64, enif_inspect_binary,
7+
enif_is_empty_list, ErlNifBinary, ErlNifCharEncoding, ErlNifEnv, ERL_NIF_TERM,
88
};
99
use rustler::{schedule, Env, MapIterator, NewBinary, Term, TermType};
1010
use std::cell::RefCell;
@@ -35,6 +35,7 @@ enum EncodeError {
3535
MalformedProplist,
3636
DepthExceeded,
3737
InvalidUtf8,
38+
UnhandledStruct,
3839
}
3940

4041
#[inline]
@@ -46,6 +47,7 @@ fn error_reason(e: EncodeError) -> ERL_NIF_TERM {
4647
EncodeError::InvalidKey => atoms::invalid_key().as_c_arg(),
4748
EncodeError::MalformedProplist => atoms::malformed_proplist().as_c_arg(),
4849
EncodeError::InvalidUtf8 => atoms::invalid_utf8().as_c_arg(),
50+
EncodeError::UnhandledStruct => atoms::unhandled_struct().as_c_arg(),
4951
}
5052
}
5153

@@ -222,6 +224,13 @@ fn encode_map(
222224
if depth == 0 {
223225
return Err(EncodeError::DepthExceeded);
224226
}
227+
// Elixir structs (maps carrying an atom `__struct__` key) are not
228+
// encodable as-is: they must opt into Torque.Encoder. The Elixir layer
229+
// normalizes them through the protocol and retries once, so failing
230+
// here on any struct keeps that fast path (no struct) at zero cost.
231+
if unsafe { is_struct(env_raw, term.as_c_arg()) } {
232+
return Err(EncodeError::UnhandledStruct);
233+
}
225234
let iter = MapIterator::new(term).ok_or(EncodeError::UnsupportedType)?;
226235
buf.push(b'{');
227236
let mut first = true;
@@ -238,6 +247,15 @@ fn encode_map(
238247
Ok(())
239248
}
240249

250+
/// A map with an atom `__struct__` key is an Elixir struct.
251+
///
252+
/// Plain maps that merely use a binary `"__struct__"` key are unaffected.
253+
#[inline]
254+
unsafe fn is_struct(env_raw: *mut ErlNifEnv, map: ERL_NIF_TERM) -> bool {
255+
let mut value = MaybeUninit::uninit();
256+
enif_get_map_value(env_raw, map, atoms::__struct__().as_c_arg(), value.as_mut_ptr()) == 1
257+
}
258+
241259
#[inline]
242260
fn encode_map_key(
243261
env_raw: *mut ErlNifEnv,

0 commit comments

Comments
 (0)