Current section
Files
Jump to
Current section
Files
lib/jsonpath.ex
defmodule JsonPath do
@moduledoc """
Minimal JSONPath implementation for Elixir.
This module provides a simple JSONPath engine with:
* Tokenization and parsing using LEEX and YECC generated Erlang modules.
* AST evaluation against Elixir maps, lists, and primitives.
* Full support for child, descendant, wildcard, slices, unions, and basic filters.
* Convenience helper functions to query a map/list without manually parsing or tokenizing.
## Example
iex> data = %{"store" => %{"book" => [%{"price" => 10}, %{"price" => 20}]}}
iex> JsonPath.query(data, "$.store.book[*].price")
[{"$['store']['book'][0]['price']", 10}, {"$['store']['book'][1]['price']", 20}]
"""
@typedoc "JSONPath AST generated by the parser"
@type ast :: {:jsonpath, :root, list(any())} | map() | {:path, list(any())}
@typedoc "A node path in the JSON structure"
@type path :: list(String.t() | integer())
@typedoc "Result of evaluating a JSONPath AST: list of {path_string, value}"
@type eval_result :: list({String.t(), any()})
@typedoc "Result of tokenize/1"
@type tokenize_result :: {:ok, list(any()), integer()} | {:error, any()}
@typedoc "Result of parse/1"
@type parse_result :: {:ok, ast()} | {:error, any()}
@doc """
Tokenizes a JSONPath query string into a list of tokens using the LEEX lexer.
Returns `{:ok, tokens, line}` or `{:error, reason}`.
## Parameters
* `query` - JSONPath string, e.g. "$.store.book[*].price"
"""
@spec tokenize(String.t()) :: tokenize_result()
def tokenize(query) when is_binary(query) do
trimmed = String.trim(query)
if trimmed != query do
{:error, "JSONPath expressions cannot have leading or trailing whitespace"}
else
ensure_loaded(:jsonpath_lexer)
:jsonpath_lexer.string(String.to_charlist(query))
end
end
@doc """
Parses a list of tokens into an AST using the YECC parser.
Returns `{:ok, ast}` or `{:error, reason}`.
"""
@spec parse(list(any())) :: parse_result()
def parse(tokens) when is_list(tokens) do
try do
ensure_loaded(:jsonpath_parser)
:jsonpath_parser.parse(tokens)
rescue
e in CaseClauseError -> {:error, e.term}
e -> {:error, e.term}
end
end
@doc """
Evaluate a JSONPath AST against data.
## Parameters
* `ast` - the parsed JSONPath AST
* `data` - Elixir map or list to evaluate against
## Returns
* list of tuples `{path_string, value}`
"""
@spec evaluate(ast(), map() | list()) :: eval_result() | {:error, any()}
def evaluate(ast, data) do
case ast do
{:jsonpath, :root, segments} when is_list(segments) -> traverse([{["$"], data}], segments)
%{root: :root, segments: segs} when is_list(segs) -> traverse([{["$"], data}], segs)
%{segments: segs} when is_list(segs) -> traverse([{["$"], data}], segs)
{:path, segs} when is_list(segs) -> traverse([{["$"], data}], segs)
other -> {:error, {:invalid_ast, other}}
end
end
@doc """
Convenience function: parse and evaluate a JSONPath query string in one call.
## Example
iex> JsonPath.query(%{"a" => 1}, "$.a")
[{"$['a']", 1}]
"""
@spec query(map() | list(), String.t()) :: eval_result() | {:error, any()}
def query(data, path_string) when is_binary(path_string) and (is_map(data) or is_list(data)) do
with {:ok, tokens, _line} <- tokenize(path_string),
{:ok, ast} <- parse(tokens) do
evaluate(ast, data)
else
{:error, reason} -> {:error, reason}
end
end
## PRIVATE HELPERS
@spec ensure_loaded(module()) :: :ok
defp ensure_loaded(module) do
case :code.is_loaded(module) do
{:file, _} -> :ok
_ -> :code.load_file(module)
end
end
@spec traverse(list({path(), any()}), list(any())) :: eval_result()
defp traverse(nodes, []), do: Enum.map(nodes, fn {p, v} -> {path_join(p), v} end)
defp traverse(nodes, [seg | rest]) do
expanded =
Enum.flat_map(nodes, fn {path, node} ->
apply_segment(seg, {path, node})
end)
traverse(expanded, rest)
end
# apply child or descendant segment
defp apply_segment({child_type, selectors}, {path, node}) do
case child_type do
:child ->
Enum.flat_map(selectors, fn sel -> apply_selector(sel, {path, node}, :child) end)
:descendant ->
# descendant: node itself then recursively into children
nodes = collect_descendants({path, node})
Enum.flat_map(nodes, fn n ->
Enum.flat_map(selectors, &apply_selector(&1, n, :descendant))
end)
end
end
defp collect_descendants({path, value}) do
# include the node itself
base = [{path, value}]
children =
case value do
%{} = m ->
Enum.flat_map(Map.to_list(m), fn {k, v} ->
collect_descendants({path ++ [to_string(k)], v})
end)
l when is_list(l) ->
Enum.with_index(l)
|> Enum.flat_map(fn {v, idx} ->
collect_descendants({path ++ [idx], v})
end)
_ ->
[]
end
base ++ children
end
defp apply_selector({:name, name}, {path, node}, _mode) when is_binary(name) do
case node do
%{} = m ->
case Map.fetch(m, name) do
{:ok, v} -> [{path ++ [name], v}]
:error -> []
end
_ ->
[]
end
end
defp apply_selector({:name, name}, {path, node}, _mode) when is_atom(name) do
apply_selector({:name, to_string(name)}, {path, node}, :child)
end
defp apply_selector({:wildcard}, {path, node}, _mode) do
case node do
%{} = m ->
Enum.map(Map.to_list(m), fn {k, v} -> {path ++ [to_string(k)], v} end)
l when is_list(l) ->
Enum.with_index(l) |> Enum.map(fn {v, i} -> {path ++ [i], v} end)
_ ->
[]
end
end
defp apply_selector({:index, idx}, {path, node}, _mode) when is_integer(idx) do
case node do
l when is_list(l) ->
n = length(l)
real_i = if idx < 0, do: n + idx, else: idx
if real_i >= 0 and real_i < n, do: [{path ++ [real_i], Enum.at(l, real_i)}], else: []
_ ->
[]
end
end
defp apply_selector({:slice, slice}, {path, node}, _mode) do
case node do
l when is_list(l) ->
indices = normalize_slice(length(l), slice)
Enum.map(indices, fn i -> {path ++ [i], Enum.at(l, i)} end)
_ ->
[]
end
end
defp apply_selector({:union, items}, {path, node}, mode) do
Enum.flat_map(items, fn item ->
apply_selector(item, {path, node}, mode)
end)
end
defp apply_selector({:filter, expr}, {path, node}, _mode) do
# filter applies to arrays/objects; for objects, iterate its children; for arrays, iterate elements
case node do
l when is_list(l) ->
Enum.with_index(l)
|> Enum.flat_map(fn {v, i} ->
if eval_filter(expr, v), do: [{path ++ [i], v}], else: []
end)
%{} = m ->
Enum.flat_map(Map.to_list(m), fn {k, v} ->
if eval_filter(expr, v), do: [{path ++ [to_string(k)], v}], else: []
end)
_ ->
[]
end
end
# slice normalization: accepts slice shape produced by parser
defp normalize_slice(len, {:start_end_step, s, e, step}) do
range_from_slice(len, s, e, step)
end
defp normalize_slice(len, {:start_end, s, e}), do: range_from_slice(len, s, e, 1)
defp normalize_slice(len, {:start_omitted_end, e}), do: range_from_slice(len, 0, e, 1)
defp normalize_slice(len, {:start_end_omitted, s}), do: range_from_slice(len, s, len, 1)
defp normalize_slice(len, {:start_omitted_end_step, step}),
do: range_from_slice(len, 0, len, step)
defp normalize_slice(_len, {:omitted_all}), do: []
defp range_from_slice(len, start, stop, step) do
st = if start < 0, do: max(len + start, 0), else: min(start, len)
sp = if stop < 0, do: max(len + stop, 0), else: min(stop, len)
cond do
step == 0 ->
[]
step > 0 ->
if st >= sp do
[]
else
st..(sp - 1)//step |> Enum.to_list()
end
step < 0 ->
if st <= sp do
[]
else
st..(sp + 1)//step |> Enum.to_list()
end
end
end
## FILTER EVAL: evaluation of filter AST nodes against a value.
## Basic implementation: literals, existence tests via `@.name` are supported, comparisons.
defp eval_filter({:or, a, b}, node), do: eval_filter(a, node) or eval_filter(b, node)
defp eval_filter({:and, a, b}, node), do: eval_filter(a, node) and eval_filter(b, node)
defp eval_filter({:not, a}, node), do: not eval_filter(a, node)
defp eval_filter({:cmp, op, left, right}, node) do
l = eval_primary(left, node)
r = eval_primary(right, node)
compare_values(op, l, r)
end
defp eval_filter({:query, :relative, qsegs}, node) do
# run a tiny singular query starting at node; returns truthy if selects >= 1
results = run_singular_query(node, qsegs)
length(results) > 0
end
defp eval_filter({:query, :absolute, _}, _node) do
# absolute ($) not implemented in filter context for demo; return false
false
end
defp eval_primary({:lit, v}, _node), do: v
defp eval_primary({:query, :relative, qsegs}, node) do
# return first literal value if query returns one element
case run_singular_query(node, qsegs) do
[{_path, val} | _] -> val
[] -> nil
end
end
defp compare_values(:eq, a, b), do: a == b
defp compare_values(:ne, a, b), do: a != b
defp compare_values(:lt, a, b) when is_number(a) and is_number(b), do: a < b
defp compare_values(:le, a, b) when is_number(a) and is_number(b), do: a <= b
defp compare_values(:gt, a, b) when is_number(a) and is_number(b), do: a > b
defp compare_values(:ge, a, b) when is_number(a) and is_number(b), do: a >= b
defp compare_values(_, _, _), do: false
defp run_singular_query(node, qsegs) do
# qsegs is list of {:qname, name} or {:qindex, idx}
# start at node with path []
walk_query([{[], node}], qsegs)
end
defp walk_query(nodes, []), do: nodes
defp walk_query(nodes, [seg | rest]) do
next =
Enum.flat_map(nodes, fn {path, node} ->
case seg do
{:qname, name} ->
case node do
%{} = m ->
name_str = to_string(name)
case Map.fetch(m, name_str) do
{:ok, v} -> [{path ++ [name_str], v}]
:error -> []
end
_ ->
[]
end
{:qindex, idx} when is_integer(idx) ->
case node do
l when is_list(l) ->
n = length(l)
i = if idx < 0, do: n + idx, else: idx
if i >= 0 and i < n, do: [{path ++ [i], Enum.at(l, i)}], else: []
_ ->
[]
end
end
end)
walk_query(next, rest)
end
defp path_join(parts) do
"$" <>
Enum.map_join(tl(parts), "", fn
part when is_integer(part) -> "[#{part}]"
part when is_binary(part) -> "['#{part}']"
end)
end
end