Current section

Files

Jump to
ash_age lib query.ex
Raw

lib/query.ex

defmodule AshAge.Query do
@moduledoc """
Query structure for AGE graph queries.
"""
defstruct [
:resource,
:graph,
:label,
:repo,
:tenant,
:expression,
:limit,
:offset,
filters: [],
sort: [],
params: %{}
]
@type t :: %__MODULE__{
resource: module(),
graph: atom() | String.t(),
label: atom() | String.t(),
repo: module(),
tenant: term() | nil,
expression: Ash.Filter.t() | nil,
limit: non_neg_integer() | nil,
offset: non_neg_integer() | nil,
filters: [String.t()],
sort: [{atom(), :asc | :desc}],
params: map()
}
@doc """
Converts a query to Cypher with parameters.
Returns `{cypher_string, params_map}`.
"""
@spec to_cypher(t()) :: {String.t(), map()}
def to_cypher(%__MODULE__{} = query) do
# Defense-in-depth: `label` feeds the cypher body and is only otherwise
# validated at compile time — re-assert it here so a non-identifier can never
# inject Cypher or break AGE dollar-quoting.
label = AshAge.Migration.validate_identifier!(query.label)
{where_parts, query} = build_where(query)
parts =
["MATCH (n:#{label})"] ++
build_where_clause(where_parts) ++
["RETURN n"] ++
build_order_by(query.sort) ++
build_skip(query.offset) ++
build_limit(query.limit)
{Enum.join(parts, " "), query.params}
end
@doc """
Adds a parameter to the query, returning the updated query and a `$paramN` reference.
"""
@spec add_param(t(), term()) :: {t(), String.t()}
def add_param(%__MODULE__{params: params} = query, value) do
key = next_param_key(params, map_size(params) + 1)
{%{query | params: Map.put(params, key, value)}, "$#{key}"}
end
# Returns the next free `paramN` key, skipping any already taken. On the
# update/destroy scoping path the params map is pre-seeded with SET-attribute
# and `match_<pk>` keys; a resource attribute literally named `paramN` must
# neither clobber nor be clobbered by a filter-scoping param.
defp next_param_key(params, n) do
key = "param#{n}"
if Map.has_key?(params, key), do: next_param_key(params, n + 1), else: key
end
defp build_where(query) do
filter_clauses = query.filters
{expression_clauses, query} =
if query.expression do
case AshAge.Query.Filter.translate(query.expression, query) do
{:ok, query, ""} -> {[], query}
{:ok, query, clause} -> {[clause], query}
_ -> {[], query}
end
else
{[], query}
end
{filter_clauses ++ expression_clauses, query}
end
defp build_where_clause([]), do: []
defp build_where_clause(parts) do
["WHERE " <> Enum.join(parts, " AND ")]
end
defp build_order_by([]), do: []
defp build_order_by(sort_clauses) do
order =
Enum.map_join(sort_clauses, ", ", fn {field, direction} ->
# Field names are interpolated into the cypher body — validate as identifiers.
field = AshAge.Migration.validate_identifier!(field)
dir = if direction == :desc, do: "DESC", else: "ASC"
"n.#{field} #{dir}"
end)
["ORDER BY " <> order]
end
defp build_skip(nil), do: []
defp build_skip(offset) when is_integer(offset) and offset >= 0, do: ["SKIP #{offset}"]
defp build_skip(offset) do
raise ArgumentError, "invalid offset: #{inspect(offset)} (expected a non-negative integer)"
end
defp build_limit(nil), do: []
defp build_limit(limit) when is_integer(limit) and limit >= 0, do: ["LIMIT #{limit}"]
defp build_limit(limit) do
raise ArgumentError, "invalid limit: #{inspect(limit)} (expected a non-negative integer)"
end
end