Current section
Files
Jump to
Current section
Files
lib/toon/decoder/expand.ex
defmodule ExToon.Decoder.Expand do
@moduledoc false
# Path expansion — converts dotted keys like "a.b.c" into nested objects.
#
# Only expands when ALL dot-separated segments are valid unquoted identifiers
# matching ^[A-Za-z_][A-Za-z0-9_]*$ (no dots within a segment).
# Keys that do not match are left unchanged.
# Keys that were originally quoted (tagged as {:toon_literal, k}) are never expanded.
#
# expand_ordered/2 operates on {:toon_ordered_object, pairs} structures produced by
# EventBuilder.build_for_expand/1, preserving document order for correct LWW semantics.
# When two keys would conflict:
# - strict=true → raises ExToon.DecodeError
# - strict=false → LWW: the later key (in document order) wins
alias ExToon.DecodeError
# Expand path: ordered, conflict-aware entry point.
# Called from Core.decode_lines when expand_paths == :safe.
@spec expand_ordered(term(), keyword()) :: term()
def expand_ordered({:toon_ordered_object, pairs}, opts) do
strict = Keyword.get(opts, :strict, true)
Enum.reduce(pairs, %{}, fn {key, raw_value}, acc ->
value = expand_ordered(raw_value, opts)
case key do
{:toon_literal, k} ->
# Quoted key: treat as a literal string, never expand.
overwrite_merge(acc, k, value, strict)
k when is_binary(k) ->
segments = String.split(k, ".")
if length(segments) > 1 and Enum.all?(segments, &valid_segment?/1) do
nested = build_nested(segments, value)
conflict_merge(acc, nested, strict)
else
overwrite_merge(acc, k, value, strict)
end
end
end)
end
def expand_ordered(list, opts) when is_list(list) do
Enum.map(list, fn item -> expand_ordered(item, opts) end)
end
def expand_ordered(v, _opts), do: v
# ---------------------------------------------------------------------------
# Legacy entry point (for expand_paths=:safe without order metadata).
# Only used when EventBuilder.build (not build_for_expand) was called.
# ---------------------------------------------------------------------------
@spec expand(term()) :: term()
def expand(map) when is_map(map) do
Enum.reduce(map, %{}, fn {key, value}, acc ->
expanded_value = expand(value)
segments = String.split(key, ".")
if length(segments) > 1 and Enum.all?(segments, &valid_segment?/1) do
nested = build_nested(segments, expanded_value)
deep_merge(acc, nested)
else
Map.put(acc, key, expanded_value)
end
end)
end
def expand(list) when is_list(list), do: Enum.map(list, &expand/1)
def expand(v), do: v
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# A valid segment is a bare identifier — no dots, starts with letter or underscore
defp valid_segment?(seg), do: Regex.match?(~r/^[A-Za-z_][A-Za-z0-9_]*$/, seg)
defp build_nested([key], value), do: %{key => value}
defp build_nested([key | rest], value), do: %{key => build_nested(rest, value)}
# Merge for non-expansion keys: new value (LWW — document order is preserved in the
# reduce loop, so later keys naturally overwrite earlier ones via Map.put).
defp overwrite_merge(acc, key, value, strict) do
case Map.fetch(acc, key) do
{:ok, existing} when is_map(existing) and not is_map(value) ->
if strict do
throw({:decode_error, %DecodeError{
reason: :expansion_conflict,
message:
"path expansion conflict: key #{inspect(key)} is both an object and a primitive"
}})
else
Map.put(acc, key, value)
end
{:ok, existing} when not is_map(existing) and is_map(value) ->
if strict do
throw({:decode_error, %DecodeError{
reason: :expansion_conflict,
message:
"path expansion conflict: key #{inspect(key)} is both a primitive and an object"
}})
else
Map.put(acc, key, value)
end
_ ->
Map.put(acc, key, value)
end
end
# Merge for expansion keys: perform deep merge, detecting conflicts.
defp conflict_merge(acc, new_map, strict) do
Map.merge(acc, new_map, fn key, v1, v2 ->
cond do
is_map(v1) and is_map(v2) ->
conflict_merge(v1, v2, strict)
is_map(v1) and not is_map(v2) ->
if strict do
throw({:decode_error, %DecodeError{
reason: :expansion_conflict,
message:
"path expansion conflict at key #{inspect(key)}: object vs primitive"
}})
else
v2
end
not is_map(v1) and is_map(v2) ->
if strict do
throw({:decode_error, %DecodeError{
reason: :expansion_conflict,
message:
"path expansion conflict at key #{inspect(key)}: primitive vs object"
}})
else
v2
end
true ->
v2
end
end)
end
# Simple deep merge (used by the legacy expand/1 path).
defp deep_merge(map1, map2) do
Map.merge(map1, map2, fn _key, v1, v2 ->
if is_map(v1) and is_map(v2) do
deep_merge(v1, v2)
else
v2
end
end)
end
end