Current section

Files

Jump to
torque lib torque.ex
Raw

lib/torque.ex

defmodule Torque do
@moduledoc """
High-performance JSON library powered by sonic-rs via Rustler NIFs.
## Decoding strategies
* **Parse + Get** — `parse/2` returns an opaque document reference.
`get/2`, `get/3`, `get_many/2`, and `get_many_nil/2` extract fields
by JSON Pointer (RFC 6901) paths without materializing the full
Elixir term tree. Ideal when only a subset of fields is needed.
* **Compiled pointers** — when the same fixed set of paths is extracted
from every document, `compile_pointers/2` pre-parses the paths once and
`parse_get_many_nil/2` fuses the parse and extraction into a single NIF
call. Skips all per-request path parsing — roughly 1.5× faster end-to-end
than `parse/2` + `get_many_nil/2`.
* **Full decode** — `decode/1` converts an entire JSON binary into
Elixir terms in one pass.
## Encoding
`encode/1` serializes Elixir terms to JSON. Supports maps (atom or
binary keys), lists, binaries, numbers, booleans, `nil`, and
jiffy-style `{proplist}` tuples.
## Scheduler awareness
Inputs larger than 20 KB are automatically dispatched to a dirty CPU
scheduler to avoid blocking normal BEAM schedulers.
## Type conversion
| JSON | Elixir |
|------|--------|
| object | map with binary keys |
| array | list |
| string | binary |
| integer | integer |
| float | float |
| `true` / `false` | `true` / `false` |
| `null` | `nil` |
For objects with duplicate keys, the last value wins (unless
`unique_keys: true` is passed to `parse/2`).
"""
@timeslice_bytes 20_480
@typedoc """
An opaque handle to a set of pre-compiled JSON Pointer paths, returned by
`compile_pointers/2`. Pass it to `parse_get_many_nil/2` or `get_many_nil/2`
in place of a path list to skip per-call path parsing.
"""
@opaque pointers :: reference()
# --- Decoding ---
@doc """
Decodes a JSON binary into Elixir terms.
JSON objects become maps with binary keys, arrays become lists, strings
become binaries, numbers become integers or floats, booleans become
`true`/`false`, and `null` becomes `nil`.
Integers outside the signed/unsigned 64-bit range decode as exact
arbitrary-precision integers (Erlang bignums) rather than lossy floats.
Automatically uses a dirty CPU scheduler for inputs larger than 20 KB.
## Examples
iex> Torque.decode(~s({"a":1,"b":"hello"}))
{:ok, %{"a" => 1, "b" => "hello"}}
iex> Torque.decode(~s([1,2,3]))
{:ok, [1, 2, 3]}
iex> match?({:error, _}, Torque.decode("invalid"))
true
"""
@doc group: :decode
@spec decode(binary()) :: {:ok, term()} | {:error, binary() | :nesting_too_deep}
def decode(json) when is_binary(json) and byte_size(json) > @timeslice_bytes do
Torque.Native.decode_dirty(json)
end
def decode(json) when is_binary(json) do
Torque.Native.decode(json)
end
@doc """
Decodes a JSON binary into Elixir terms, raising on error.
## Examples
iex> Torque.decode!(~s({"a":1}))
%{"a" => 1}
"""
@doc group: :decode
@spec decode!(binary()) :: term()
def decode!(json) when is_binary(json) do
case decode(json) do
{:ok, term} -> term
{:error, reason} -> raise ArgumentError, "decode error: #{reason}"
end
end
# --- Encoding ---
@doc """
Encodes an Elixir term into a JSON binary.
## Supported terms
* Maps with atom or binary keys
* Lists (JSON arrays)
* Binaries (JSON strings)
* Integers and floats
* `true`, `false`, `nil` (JSON `null`)
* Other atoms (encoded as JSON strings)
* `{keyword_list}` tuples (jiffy-style proplist objects)
## Examples
iex> Torque.encode(%{id: "abc", price: 1.5})
{:ok, ~s({"id":"abc","price":1.5})}
iex> Torque.encode({[{:id, "abc"}]})
{:ok, ~s({"id":"abc"})}
"""
@doc group: :encode
@spec encode(term()) :: {:ok, binary()} | {:error, binary() | :nesting_too_deep}
def encode(term) do
Torque.Native.encode(term)
end
@doc """
Encodes an Elixir term into a JSON binary, raising on error.
## Examples
iex> Torque.encode!(%{ok: true})
~s({"ok":true})
"""
@doc group: :encode
@spec encode!(term()) :: binary()
def encode!(term) do
case encode(term) do
{:ok, json} -> json
{:error, reason} -> raise ArgumentError, "encode error: #{reason}"
end
end
@doc """
Encodes an Elixir term into a JSON binary (iodata-compatible).
Returns the binary directly without `{:ok, ...}` tuple wrapping.
Raises on error. This is the fastest encoding path when the result
is passed directly to I/O (e.g. as an HTTP response body).
## Examples
iex> Torque.encode_to_iodata(%{ok: true})
~s({"ok":true})
"""
@doc group: :encode
@spec encode_to_iodata(term()) :: binary()
def encode_to_iodata(term) do
Torque.Native.encode_iodata(term)
catch
:error, value -> raise ArgumentError, "encode error: #{inspect(value)}"
end
# --- Parse + Get ---
@doc """
Parses a JSON binary into an opaque document reference.
The returned reference can be passed to `get/2`, `get/3`, `get_many/2`,
`get_many_nil/2`, or `length/2` for efficient repeated field extraction
without re-parsing.
## Options
* `:unique_keys` — when `true`, assumes object keys are unique and uses
a faster lookup path. Defaults to `false` (last-value-wins for
duplicate keys).
Automatically uses a dirty CPU scheduler for inputs larger than 20 KB.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":1}))
iex> is_reference(doc)
true
iex> {:ok, doc} = Torque.parse(~s({"a":1}), unique_keys: true)
iex> Torque.get(doc, "/a")
{:ok, 1}
"""
@doc group: :parse_get
@spec parse(binary(), keyword()) :: {:ok, reference()} | {:error, binary()}
def parse(json, opts \\ [])
def parse(json, []) when is_binary(json) and byte_size(json) > @timeslice_bytes do
Torque.Native.parse_dirty(json)
end
def parse(json, []) when is_binary(json) do
Torque.Native.parse(json)
end
def parse(json, opts) when is_binary(json) and byte_size(json) > @timeslice_bytes do
Torque.Native.parse_opts_dirty(json, Keyword.get(opts, :unique_keys, false))
end
def parse(json, opts) when is_binary(json) do
Torque.Native.parse_opts(json, Keyword.get(opts, :unique_keys, false))
end
@doc """
Extracts a value from a parsed document using a JSON Pointer path (RFC 6901).
Paths must start with `"/"`. Array elements are addressed by index
(e.g. `"/imp/0/banner/w"`). An empty path `""` returns the root value.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"site":{"domain":"example.com"}}))
iex> Torque.get(doc, "/site/domain")
{:ok, "example.com"}
iex> {:ok, doc} = Torque.parse(~s({"site":{"domain":"example.com"}}))
iex> Torque.get(doc, "/missing")
{:error, :no_such_field}
"""
@doc group: :parse_get
@spec get(reference(), binary()) ::
{:ok, term()} | {:error, :no_such_field | :nesting_too_deep}
def get(doc, path) when is_reference(doc) and is_binary(path) do
Torque.Native.get(doc, path)
end
@doc """
Extracts a value from a parsed document, returning `default` when the path
does not exist.
Raises `ArgumentError` for errors other than `:no_such_field`
(e.g. `:nesting_too_deep`).
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":1}))
iex> Torque.get(doc, "/a", nil)
1
iex> {:ok, doc} = Torque.parse(~s({"a":1}))
iex> Torque.get(doc, "/b", :default)
:default
"""
@doc group: :parse_get
@compile {:inline, get: 3}
@spec get(reference(), binary(), term()) :: term()
def get(doc, path, default) when is_reference(doc) and is_binary(path) do
case Torque.Native.get(doc, path) do
{:ok, value} -> value
{:error, :no_such_field} -> default
{:error, reason} -> raise ArgumentError, "get error: #{reason}"
end
end
@doc """
Extracts multiple values from a parsed document in a single NIF call.
Returns a list of results in the same order as `paths`, each being
`{:ok, value}` or `{:error, :no_such_field}`.
More efficient than calling `get/2` in a loop because it crosses
the NIF boundary only once.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":2}))
iex> Torque.get_many(doc, ["/a", "/b", "/c"])
[{:ok, 1}, {:ok, 2}, {:error, :no_such_field}]
"""
@doc group: :parse_get
@spec get_many(reference(), [binary()]) ::
[{:ok, term()} | {:error, :no_such_field | :nesting_too_deep}]
def get_many(doc, paths) when is_reference(doc) and is_list(paths) do
Torque.Native.get_many(doc, paths)
end
@doc """
Extracts multiple values from a parsed document, returning `nil` for missing
fields.
Like `get_many/2` but returns bare values instead of `{:ok, value}` tuples.
Missing fields return `nil` (indistinguishable from JSON `null`).
Faster than `get_many/2` when you don't need to distinguish between
missing fields and null values, as it avoids allocating wrapper tuples.
Accepts either a list of JSON Pointer path strings or a `t:pointers/0` handle
built by `compile_pointers/2`. The compiled form skips all per-call path
parsing and is the recommended option for a fixed, repeatedly-queried path
set.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":null}))
iex> Torque.get_many_nil(doc, ["/a", "/b", "/c"])
[1, nil, nil]
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":null}))
iex> ptrs = Torque.compile_pointers(["/a", "/b", "/c"])
iex> Torque.get_many_nil(doc, ptrs)
[1, nil, nil]
"""
@doc group: :parse_get
@spec get_many_nil(reference(), [binary()] | pointers()) :: [term()]
def get_many_nil(doc, paths) when is_reference(doc) and is_list(paths) do
Torque.Native.get_many_nil(doc, paths)
end
def get_many_nil(doc, pointers) when is_reference(doc) and is_reference(pointers) do
Torque.Native.get_many_nil_compiled(doc, pointers)
end
@doc """
Pre-compiles a list of JSON Pointer paths into a reusable handle.
Workloads that parse many documents and extract the *same* fixed set of fields
re-split and unescape those pointer strings on every call — wasted work, since
they never change. `compile_pointers/2` does it once and returns an opaque
`t:pointers/0` handle that `parse_get_many_nil/2` and `get_many_nil/2` accept
in place of a path list, eliminating all per-call path parsing (≈2× faster
extraction on a typical field set).
Compile once at startup (e.g. into a module attribute or `:persistent_term`)
and reuse the handle for every document.
## Options
* `:unique_keys` — when `true`, object key lookups use a forward scan that
stops at the first match (faster). Defaults to `false` (reverse scan,
last-value-wins for duplicate keys), matching `parse/2`. Safe to enable
when keys are known to be unique.
Extraction results are returned in the same order as `paths`.
## Examples
iex> ptrs = Torque.compile_pointers(["/a", "/b/0"], unique_keys: true)
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":[2,3]}))
iex> Torque.get_many_nil(doc, ptrs)
[1, 2]
"""
@doc group: :parse_get
@spec compile_pointers([binary()], keyword()) :: pointers()
def compile_pointers(paths, opts \\ []) when is_list(paths) do
Torque.Native.compile_paths(paths, Keyword.get(opts, :unique_keys, false))
end
@doc """
Parses a JSON binary and extracts pre-compiled pointers in a single NIF call.
Fuses `parse/2` and `get_many_nil/2` for the common parse-once-extract-once
case: it parses the document, extracts each compiled pointer, and returns the
values — without materializing a reusable document handle or crossing the NIF
boundary twice. Missing fields and JSON `null` both become `nil`. The lookup
strategy (`:unique_keys`) is taken from the `t:pointers/0` handle.
Returns `{:ok, values}` (in the same order as the paths given to
`compile_pointers/2`) or `{:error, reason}` if the JSON is malformed.
Automatically uses a dirty CPU scheduler for inputs larger than 20 KB.
## Examples
iex> ptrs = Torque.compile_pointers(["/id", "/site/domain", "/missing"])
iex> Torque.parse_get_many_nil(~s({"id":"x","site":{"domain":"e.com"}}), ptrs)
{:ok, ["x", "e.com", nil]}
iex> ptrs = Torque.compile_pointers(["/a"])
iex> match?({:error, _}, Torque.parse_get_many_nil("not json", ptrs))
true
"""
@doc group: :parse_get
@spec parse_get_many_nil(binary(), pointers()) ::
{:ok, [term()]} | {:error, binary() | :nesting_too_deep}
def parse_get_many_nil(json, pointers)
when is_binary(json) and is_reference(pointers) and byte_size(json) > @timeslice_bytes do
Torque.Native.parse_get_many_nil_dirty(json, pointers)
end
def parse_get_many_nil(json, pointers) when is_binary(json) and is_reference(pointers) do
Torque.Native.parse_get_many_nil(json, pointers)
end
@doc """
Extracts multiple values from a parsed document with per-path defaults.
Takes a map of `%{path => default}` and returns a map of the same shape
where each value is either the parsed value or the supplied default (if
the path is missing).
More ergonomic than the two-call `get_many_nil/2` + `Enum.map` pattern
when consumers need defaults at the call site.
Equivalent to:
get_many_nil(doc, Map.keys(defaults))
|> then(&Enum.zip(Map.keys(defaults), &1))
|> Map.new(fn {p, nil} -> {p, Map.get(defaults, p)}; pv -> pv end)
Note: a parsed JSON `null` at the path is indistinguishable from a missing
field (same as `get_many_nil/2`) — both substitute the default.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":1,"b":null}))
iex> Torque.get_many_defaults(doc, %{"/a" => 0, "/b" => 0, "/c" => "missing"})
%{"/a" => 1, "/b" => 0, "/c" => "missing"}
"""
@doc group: :parse_get
@spec get_many_defaults(reference(), %{binary() => term()}) ::
%{binary() => term()}
def get_many_defaults(doc, defaults)
when is_reference(doc) and is_map(defaults) do
paths = Map.keys(defaults)
values = Torque.Native.get_many_nil(doc, paths)
paths
|> Enum.zip(values)
|> Map.new(fn
{path, nil} -> {path, Map.get(defaults, path)}
pv -> pv
end)
end
@doc """
Returns the length of an array at the given JSON Pointer path, or `nil` if
the path does not exist or does not point to an array.
## Examples
iex> {:ok, doc} = Torque.parse(~s({"a":[1,2,3]}))
iex> Torque.length(doc, "/a")
3
iex> {:ok, doc} = Torque.parse(~s({"a":[1,2,3]}))
iex> Torque.length(doc, "/missing")
nil
"""
@doc group: :parse_get
@spec length(reference(), binary()) :: non_neg_integer() | nil
def length(doc, path) when is_reference(doc) and is_binary(path) do
Torque.Native.array_length(doc, path)
end
end