Current section
Files
Jump to
Current section
Files
lib/changes/edge_cypher.ex
defmodule AshAge.Changes.EdgeCypher do
@moduledoc false
# Shared, security-critical helpers for the edge change modules
# (`AshAge.Changes.CreateEdge` / `DestroyEdge`). Both build parameterized edge
# Cypher from the same endpoint identification, identifier validation, and
# tenant-scoping rules; keeping that logic in ONE place prevents the two
# modules' tenant/injection clauses from silently diverging (a divergence in
# `tenant_where/1` would be a cross-tenant write hole). Same dependency level
# as its callers (above the data layer): imports `Info` (L3) / `Migration` (L0).
alias AshAge.Cypher.Parameterized
alias AshAge.DataLayer.Info
alias AshAge.Migration
alias AshAge.Type.Cast
@doc false
# Wraps Parameterized.safe_build (the one home of the encode-failure rescue
# classifier) so a non-JSON-encodable property/param fails closed as a
# value-free tuple instead of a raise whose message embeds the bytes
# (AGENTS.md rule 5), keeping the edge paths' string-reason error contract.
def safe_build(graph, cypher, params, return_types) do
case Parameterized.safe_build(graph, cypher, params, return_types) do
{:ok, built} ->
{:ok, built}
{:error, :params_not_json_encodable} ->
{:error, "edge parameters not JSON-encodable (raw binary in a non-binary-typed value?)"}
end
end
@doc false
# Resolves the named edge on the resource, raising if it isn't declared.
def fetch_edge!(resource, name) do
case Enum.find(Info.edges(resource), &(&1.name == name)) do
%AshAge.Edge{} = edge -> edge
nil -> raise ArgumentError, "no `edge #{inspect(name)}` declared on #{inspect(resource)}"
end
end
@doc false
# The resource's AGE vertex label, validated as an AGE identifier.
def validated_label(resource), do: resource |> Info.label() |> Migration.validate_identifier!()
@doc false
# The destination's single-attribute primary key name, validated. Edge
# destinations must have a single-attribute PK.
def destination_pk!(resource) do
case Ash.Resource.Info.primary_key(resource) do
[single] -> single |> to_string() |> Migration.validate_identifier!()
_ -> raise ArgumentError, "edge destinations must have a single-attribute primary key"
end
end
@doc false
# Destination endpoint values serialized by the DESTINATION RESOURCE's PK
# attribute type — the `to:` argument's declared type does not govern the
# stored wire form (spec C6). Resolves the PK and type spec ONCE for the
# whole list (the edge write loops run once per destination; per-id
# re-introspection was the S7-closeout O1 advisory). Single-attribute PK is
# enforced by destination_pk! at the build site, so this match cannot fail.
# Shared by CreateEdge and DestroyEdge for the same anti-divergence reason
# as source_key/2.
def serialize_destination_ids(destination, dest_ids) do
[dest_pk_attr] = Ash.Resource.Info.primary_key(destination)
spec = Map.get(Info.attribute_types(destination), dest_pk_attr)
Enum.map(dest_ids, &Cast.serialize_value(&1, spec))
end
@doc false
# A map of source PK field (string) => value, read from the PERSISTED record
# (its original identity), not the pending changeset. Values are serialized by
# the SOURCE RESOURCE's attribute types (binary-storage → tagged) so the WHERE
# matches the stored wire form.
def source_key(resource, record) do
types = Info.attribute_types(resource)
resource
|> Ash.Resource.Info.primary_key()
|> Map.new(fn f ->
{to_string(f), Cast.serialize_value(Map.get(record, f), Map.get(types, f))}
end)
end
@doc false
# Builds the source WHERE clause (`a.<pk> = $src_<pk>`), each field validated
# and each value bound as a `$param`. Returns `{clause, params}`.
def source_where(src_key) do
{clauses, params} =
Enum.reduce(src_key, {[], %{}}, fn {field, value}, {clauses, params} ->
field = Migration.validate_identifier!(field)
key = "src_#{field}"
{["a.#{field} = $#{key}" | clauses], Map.put(params, key, value)}
end)
{clauses |> Enum.reverse() |> Enum.join(" AND "), params}
end
@doc false
# For an `:attribute`-multitenant source, the `{src_attr, dest_attr, value}`
# spec that scopes BOTH endpoints by the tenant discriminator; `nil` otherwise.
def tenant_spec(resource, edge, changeset) do
if Ash.Resource.Info.multitenancy_strategy(resource) == :attribute do
{Ash.Resource.Info.multitenancy_attribute(resource),
Ash.Resource.Info.multitenancy_attribute(edge.destination), changeset.to_tenant}
else
nil
end
end
@doc false
# Builds the tenant WHERE fragment. A non-multitenant destination (nil
# dest_attr) takes no destination clause. Returns `{clause, params}`.
def tenant_where(nil), do: {"", %{}}
def tenant_where({src_attr, dest_attr, value}) do
src_attr = src_attr |> to_string() |> Migration.validate_identifier!()
dest = if dest_attr, do: dest_attr |> to_string() |> Migration.validate_identifier!()
clause =
" AND a.#{src_attr} = $tenant" <> if(dest, do: " AND b.#{dest} = $tenant", else: "")
{clause, %{"tenant" => value}}
end
end