Current section
Files
Jump to
Current section
Files
lib/type/cast.ex
defmodule AshAge.Type.Cast do
@moduledoc """
Cast functions for mapping AGE vertex data to Ash resource attributes.
"""
alias AshAge.Type.Vertex
# Wire-format tag prefixing every ash_age-encoded binary value. Its purpose is
# to make read-back deterministic: a stored string carries the tag iff ash_age
# base64-encoded it, so decoding is never a guess. Legacy or externally-written
# values (no tag) are returned verbatim — a value that merely *looks* like
# base64 is never silently decoded. `$` is outside both base64 alphabets, so the
# tag can never collide with the encoded body. Values reach Cypher only as the
# `$1` JSON parameter, so the `$` in the tag never touches query syntax.
@binary_tag "$age64$"
@doc false
# Encodes a raw binary value for AGE storage: tag + base64. Used by
# serialize_value/2 below on the write path (the encode counterpart of the
# `@binary_tag`-prefixed coerce clause below).
def encode_binary(value) when is_binary(value), do: @binary_tag <> Base.encode64(value)
@doc false
# Resolves `type` to its Ash STORAGE class (`:binary`, `:date`,
# `:utc_datetime`, `:naive_datetime`, `:string`, ...), through builtin
# aliases and `Ash.Type.NewType` wrappers, honoring `constraints` exactly
# the way Ash itself keys sort capability (`Ash.Type.storage_type/2`). This
# is the ONE type-resolution home — the encoder, the decode gate, the filter
# cast, and the classification verifiers all key off it (directly or via
# binary_storage?/2), so a type can never be handled by one path and missed
# by another. `nil`/unknown types → nil.
def storage_class(type, constraints \\ [])
def storage_class(nil, _constraints), do: nil
def storage_class(type, constraints) do
resolved = Ash.Type.get_type(type)
if Ash.Type.ash_type?(resolved), do: Ash.Type.storage_type(resolved, constraints), else: nil
end
@doc false
# TRUE when `type` stores as raw bytes — the classification predicate the
# verifiers, the range/sort gates, and the encoder share.
def binary_storage?(type, constraints \\ []), do: storage_class(type, constraints) == :binary
# A "type spec" is a bare type (atom / module / `{:array, inner}`) or a
# `{type, constraints}` tuple. Constraints are ALWAYS a keyword list, and an
# array type's second element is always a type — the `is_list` guard cannot
# confuse the two shapes. `Info.attribute_types/1` emits the tuple form, so
# attribute constraints reach every wire path (encoder, decode gate, filter
# cast) — the same constraints the verifiers and range/sort gates check;
# without this, a type whose storage class depends on constraints would
# verify one way and store another.
defp normalize_spec({type, constraints}) when is_list(constraints), do: {type, constraints}
defp normalize_spec(type), do: {type, []}
@doc false
# Serializes an attribute/argument value for AGE storage or match-param use.
# Binary-storage values are tagged + base64-encoded so raw bytes survive
# `Jason.encode!` and read-back is deterministic; date/datetime values become
# ISO8601; everything else passes through. Accepts a type spec (see
# normalize_spec/1). Moved from AshAge.DataLayer in S7 so level-3 modules
# (Query.Filter) can call it without importing level 5.
def serialize_value(%DateTime{} = dt, _spec), do: DateTime.to_iso8601(dt)
def serialize_value(%NaiveDateTime{} = ndt, _spec), do: NaiveDateTime.to_iso8601(ndt)
def serialize_value(%Date{} = d, _spec), do: Date.to_iso8601(d)
def serialize_value(value, spec) when is_binary(value) do
{type, constraints} = normalize_spec(spec)
if binary_storage?(type, constraints), do: encode_binary(value), else: value
end
def serialize_value(value, _spec), do: value
@doc """
Converts a vertex to a map of resource attributes.
- Extracts `vertex.properties`
- Includes `vertex.id` mapped to the resource's primary key
- Applies `attribute_map` for any name remapping
- Coerces types based on `attribute_types` (e.g., ISO8601 strings -> Date/DateTime)
"""
@spec vertex_to_resource_attrs(Vertex.t(), map(), map()) :: map()
def vertex_to_resource_attrs(%Vertex{} = vertex, attribute_map, attribute_types) do
reverse_map = Map.new(attribute_map, fn {attr_name, prop_name} -> {prop_name, attr_name} end)
base_attrs =
vertex.properties
|> Enum.map(fn {key, val} ->
resolve_property(to_string(key), val, reverse_map, attribute_types)
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
maybe_put_id(base_attrs, vertex.id)
end
def vertex_to_resource_attrs(_other, _attribute_map, _attribute_types), do: %{}
defp resolve_property(prop_str, value, reverse_map, attribute_types) do
case Map.get(reverse_map, prop_str) do
nil -> resolve_by_atom(prop_str, value, attribute_types)
attr_name -> {attr_name, coerce_value(value, Map.get(attribute_types, attr_name))}
end
end
defp resolve_by_atom(prop_str, value, attribute_types) do
atom_key = safe_to_existing_atom(prop_str)
if atom_key && Map.has_key?(attribute_types, atom_key) do
{atom_key, coerce_value(value, Map.get(attribute_types, atom_key))}
end
end
defp maybe_put_id(attrs, nil), do: attrs
defp maybe_put_id(attrs, id), do: Map.put_new(attrs, :id, id)
@doc """
Coerces a decoded agtype scalar to its Ash attribute `type`.
Single source of truth for scalar coercion, shared by the vertex-read path
(`vertex_to_resource_attrs/3`) and the traversal F3 source-key path
(`AshAge.ManualRelationships.Traverse`). A `nil`/unknown `type` returns the
value verbatim (identity), so callers may pass a type map that omits a field.
"""
@spec coerce_value(term(), atom() | module() | tuple() | nil) :: term()
def coerce_value(value, spec) when is_binary(value) do
{type, constraints} = normalize_spec(spec)
# Dispatch by STORAGE class (not literal type lists) so NewType wrappers
# coerce exactly like the builtin they store as — the write side
# serializes %Date{}/%DateTime{} structurally, so the read side must be
# symmetric for every type whose storage is date/datetime, or a wrapped
# date PK silently breaks key equality (traverse F3 keyed map).
case storage_class(type, constraints) do
:date -> coerce_date(value)
:utc_datetime -> coerce_datetime(value)
:naive_datetime -> coerce_naive_datetime(value)
:binary -> coerce_binary(value)
_ -> value
end
end
def coerce_value(value, _spec), do: value
# Tag-gated decode. Untagged binary-storage values (legacy/pre-tag or
# externally written) are returned verbatim — READ-ONLY grace: as of S7 the
# match paths (filter/PK/traverse/edge) send the tagged form, so untagged
# rows are readable but not matchable through Ash. Never guess-decode a
# string that merely looks like base64.
defp coerce_binary(@binary_tag <> encoded = original) do
case Base.decode64(encoded) do
{:ok, decoded} -> decoded
# A tagged value that fails to decode is corrupt/unexpected; return it
# as stored rather than crash the read path.
:error -> original
end
end
defp coerce_binary(value), do: value
defp coerce_date(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
_ -> value
end
end
defp coerce_datetime(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
_ -> value
end
end
defp coerce_naive_datetime(value) do
case NaiveDateTime.from_iso8601(value) do
{:ok, ndt} -> ndt
_ -> value
end
end
defp safe_to_existing_atom(string) do
String.to_existing_atom(string)
rescue
ArgumentError -> nil
end
end