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_` 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