Current section

Files

Jump to
ash_age lib cypher parameterized.ex
Raw

lib/cypher/parameterized.ex

defmodule AshAge.Cypher.Parameterized do
@moduledoc """
Parameterized Cypher query builder for Apache AGE.
AGE requires queries in the form:
SELECT * FROM ag_catalog.cypher('graph_name', $$ CYPHER $$, $1) AS (v agtype)
Where `$1` is a JSON-encoded map of parameters.
"""
@doc """
Builds a parameterized Cypher query wrapped for AGE execution.
Returns `{sql_string, [json_params]}` suitable for `Ecto.Adapters.SQL.query/3`.
"""
@spec build(atom() | String.t(), String.t(), map()) :: {String.t(), list()}
def build(graph, cypher, params) do
build(graph, cypher, params, [{:v, :agtype}])
end
@doc """
Builds a parameterized Cypher query with custom return type columns.
`return_types` is a keyword list like `[{:v, :agtype}, {:e, :agtype}]`.
"""
@spec build(atom() | String.t(), String.t(), map(), keyword()) :: {String.t(), list()}
def build(graph, cypher, params, return_types) do
graph_name = validate_and_stringify_graph!(graph)
cypher = validate_cypher_body!(cypher)
columns = format_return_columns(return_types)
json_params = Jason.encode!(params)
sql = "SELECT * FROM ag_catalog.cypher('#{graph_name}', $$ #{cypher} $$, $1) AS (#{columns})"
{sql, [json_params]}
end
@doc """
`build/4` wrapped so a non-JSON-encodable param value fails closed as
`{:error, :params_not_json_encodable}` instead of raising: `Jason.EncodeError`
embeds the raw bytes in its message, and `Protocol.UndefinedError` (a struct
with no `Jason.Encoder` impl nested in a param) inspects the value into its
message. This is the ONE home for that classifier — every executing caller
(`AshAge.DataLayer.build_and_query`, `AshAge.Changes.EdgeCypher.safe_build`,
`AshAge.cypher/5`, the traverse path) routes through it, so the redaction
logic cannot drift between copies. Identifier/`$$`-breakout `ArgumentError`s
propagate unchanged: they are value-free by design and must stay loud.
"""
@spec safe_build(atom() | String.t(), String.t(), map(), keyword()) ::
{:ok, {String.t(), list()}} | {:error, :params_not_json_encodable}
def safe_build(graph, cypher, params, return_types) do
{:ok, build(graph, cypher, params, return_types)}
rescue
_e in Jason.EncodeError ->
{:error, :params_not_json_encodable}
# Redact only the Jason-protocol case; any other protocol error is an
# unrelated bug and must not be masked.
e in Protocol.UndefinedError ->
if e.protocol == Jason.Encoder do
{:error, :params_not_json_encodable}
else
reraise(e, __STACKTRACE__)
end
end
@doc """
Builds a static Cypher query (no parameters).
Omits the params argument entirely — AGE rejects `NULL` as the third argument.
"""
@spec build_static(atom() | String.t(), String.t()) :: {String.t(), list()}
def build_static(graph, cypher) do
build_static(graph, cypher, [{:v, :agtype}])
end
@doc """
Builds a static Cypher query with custom return type columns.
"""
@spec build_static(atom() | String.t(), String.t(), keyword()) :: {String.t(), list()}
def build_static(graph, cypher, return_types) do
graph_name = validate_and_stringify_graph!(graph)
cypher = validate_cypher_body!(cypher)
columns = format_return_columns(return_types)
sql =
"SELECT * FROM ag_catalog.cypher('#{graph_name}', $$ #{cypher} $$) AS (#{columns})"
{sql, []}
end
# Defense-in-depth: the cypher body is interpolated between AGE's `$$ ... $$`
# dollar-quote delimiters. Every identifier that reaches it is validated at its
# source (graph/label/field/property-key), but as a final centralized guard we
# reject any body carrying a `$$` sequence — the only way to break out of the
# dollar-quoted literal. This library never legitimately emits `$$` (all values
# are parameterized via `$1`), so this can only fire on a smuggled identifier.
defp validate_cypher_body!(cypher) when is_binary(cypher) do
if String.contains?(cypher, "$$") do
# The body is NOT echoed: this raise is not routed through the redaction
# boundary, and a caller that mistakenly interpolated a value into the body
# would otherwise leak it into logs (AGENTS.md rule 5).
raise ArgumentError, "Cypher body must not contain \"$$\" (would break AGE dollar-quoting)"
end
cypher
end
defp validate_and_stringify_graph!(graph) when is_atom(graph) do
validate_and_stringify_graph!(Atom.to_string(graph))
end
defp validate_and_stringify_graph!(graph) when is_binary(graph) do
AshAge.Migration.validate_identifier!(graph)
end
# `return_types` column names/types are interpolated into the outer `AS (...)`
# SQL, OUTSIDE AGE's `$$` dollar-quote — so on the public `AshAge.cypher/5`
# surface they are a caller-controlled injection vector. Validate both as AGE
# identifiers, the same guard applied to the graph name.
defp format_return_columns(return_types) do
Enum.map_join(return_types, ", ", fn {name, type} ->
name = name |> to_string() |> AshAge.Migration.validate_identifier!()
type = type |> to_string() |> AshAge.Migration.validate_identifier!()
"#{name} #{type}"
end)
end
end