# SPDX-FileCopyrightText: 2025 ash_neo4j contributors # # SPDX-License-Identifier: MIT defmodule AshNeo4j.Cypher do @moduledoc """ AshNeo4j Cypher Functions for converting Elixir data structures to Cypher query components and running Cypher queries against a Neo4j database. Ideally has no specific knowledge of Ash """ require Logger alias AshNeo4j.BoltyHelper alias AshNeo4j.Cypher.{ Query, Match, OptionalMatch, Create, Merge, Where, With, Set, OnCreateSet, OnMatchSet, Remove, Delete, DetachDelete, Return, OrderBy, Skip, Limit, Call } # World-extent WGS-84 corners, used to rewrite bbox-containment predicates # into index-servable range scans on the indexed companion corners (#311). # `point.withinBBox(indexedCorner, worldSW, p)` reads as `indexedCorner <= p` # and is served by the POINT index (NodeIndexSeekByRange); the natural # `point.withinBBox(p, n.bbSW, n.bbNE)` form has the indexed properties as # the box, not the probe, so it can only be a full scan. @world_sw "point({longitude: -180, latitude: -90})" @world_ne "point({longitude: 180, latitude: 90})" @spec remove_properties(atom(), maybe_improper_list()) :: binary() @doc """ Converts a list of property names into a remove properties string. The list is converted to a string in the format `n.key1, n.key2`. ## Examples ``` iex> AshNeo4j.Cypher.remove_properties(:n, [:born, :bafta_winner]) "n.born, n.bafta_winner" ``` """ def remove_properties(label, names) when is_atom(label) and is_list(names) do names |> Enum.map_join(", ", fn name -> "#{label}.#{name}" end) end @doc """ Converts a node variable, label, predicates and operator to cypher expression ## Examples ``` iex> AshNeo4j.Cypher.expression(:s, "name", "IN", "[$s_name_0]") "s.name IN [$s_name_0]" iex> AshNeo4j.Cypher.expression(:s, "name", "is_nil", true) "s.name IS NULL" iex> AshNeo4j.Cypher.expression(:s, "name", "is_nil", false) "s.name IS NOT NULL" iex> AshNeo4j.Cypher.expression(:s, "name", "contains", "$s_name_0") "s.name CONTAINS $s_name_0" iex> AshNeo4j.Cypher.expression(:s, "name", "contains", "$s_name_0", case_insensitive?: true) "toLower(s.name) CONTAINS toLower($s_name_0)" iex> AshNeo4j.Cypher.expression(:s, "name", "=", "$s_name_0", case_insensitive?: true) "toLower(s.name) = toLower($s_name_0)" iex> AshNeo4j.Cypher.expression(:n, "bounds", "within_bbox", "$test_point") "point.withinBBox(n.`bounds.bbSW`, point({longitude: -180, latitude: -90}), $test_point) AND point.withinBBox(n.`bounds.bbNE`, $test_point, point({longitude: 180, latitude: 90}))" iex> AshNeo4j.Cypher.expression(:n, "bounds", "within_bbox_box", {"$inner_sw", "$inner_ne"}) "point.withinBBox(n.`bounds.bbSW`, point({longitude: -180, latitude: -90}), $inner_sw) AND point.withinBBox(n.`bounds.bbNE`, $inner_ne, point({longitude: 180, latitude: 90}))" iex> AshNeo4j.Cypher.expression(:n, "location", "st_distance", {"<", "$test_point", "$threshold"}) "point.distance(n.location, $test_point) < $threshold" iex> AshNeo4j.Cypher.expression(:n, "location", "dwithin", {"$test_point", "$threshold"}) "point.distance(n.location, $test_point) <= $threshold" iex> AshNeo4j.Cypher.expression(:s, "embedding", "vector_similarity", {">", "$s_embedding_0_vec", "$s_embedding_0_t"}) "vector.similarity.cosine(s.embedding, $s_embedding_0_vec) > $s_embedding_0_t" iex> AshNeo4j.Cypher.expression(:s, "embedding", "vector_cosine_distance", {"<", "$s_embedding_0_vec", "$s_embedding_0_t"}) "(2.0 * (1.0 - vector.similarity.cosine(s.embedding, $s_embedding_0_vec))) < $s_embedding_0_t" ``` """ def expression(variable, left, operator, right, opts \\ []) when is_atom(variable) and is_bitstring(left) and is_bitstring(operator) do case_insensitive? = Keyword.get(opts, :case_insensitive?, false) cond do operator == "IN" && right == "[]" -> "#{variable}.#{left} IS NULL" operator == "is_nil" && right -> "#{variable}.#{left} IS NULL" operator == "is_nil" && !right -> "#{variable}.#{left} IS NOT NULL" operator == "within_bbox" -> # P ∈ [bbSW, bbNE] ⟺ bbSW ≤ P AND bbNE ≥ P. Probing the indexed corners # against world-extent boxes keeps the indexed properties as the probe, # so the POINT index serves both ranges (#311). bb_sw = "#{variable}.`#{left}.bbSW`" bb_ne = "#{variable}.`#{left}.bbNE`" "point.withinBBox(#{bb_sw}, #{@world_sw}, #{right}) AND " <> "point.withinBBox(#{bb_ne}, #{right}, #{@world_ne})" operator == "within_bbox_box" -> {sw_ref, ne_ref} = right # Test box ⊆ attr box ⟺ attr.bbSW ≤ test.sw AND attr.bbNE ≥ test.ne — # again expressed as indexed-corner range scans (#311). bb_sw = "#{variable}.`#{left}.bbSW`" bb_ne = "#{variable}.`#{left}.bbNE`" "point.withinBBox(#{bb_sw}, #{@world_sw}, #{sw_ref}) AND " <> "point.withinBBox(#{bb_ne}, #{ne_ref}, #{@world_ne})" operator == "st_distance" -> {comp_op, test_ref, threshold_ref} = right "point.distance(#{variable}.#{quote_if_dotted(left)}, #{test_ref}) #{comp_op} #{threshold_ref}" operator == "dwithin" -> {test_ref, threshold_ref} = right "point.distance(#{variable}.#{quote_if_dotted(left)}, #{test_ref}) <= #{threshold_ref}" operator == "vector_similarity" -> {comp_op, vec_ref, threshold_ref} = right "#{vector_scalar(:vector_similarity, variable, left, vec_ref)} #{comp_op} #{threshold_ref}" operator == "vector_cosine_distance" -> {comp_op, vec_ref, threshold_ref} = right "#{vector_scalar(:vector_cosine_distance, variable, left, vec_ref)} #{comp_op} #{threshold_ref}" case_insensitive? -> "toLower(#{variable}.#{left}) #{String.upcase(operator)} toLower(#{right})" true -> "#{variable}.#{left} #{String.upcase(operator)} #{right}" end end @doc """ Bare scalar Cypher for a vector function, e.g. for use in `ORDER BY`. `vec_ref` is the parameter reference holding the query embedding (`"$q"`). `vector_similarity` is Neo4j's normalised cosine similarity in `[0, 1]` (higher = closer); `vector_cosine_distance` rescales it to pgvector-style distance in `[0, 2]` (lower = closer) via `2 * (1 - similarity)`. ## Examples ``` iex> AshNeo4j.Cypher.vector_scalar(:vector_similarity, :s, "embedding", "$q") "vector.similarity.cosine(s.embedding, $q)" iex> AshNeo4j.Cypher.vector_scalar(:vector_cosine_distance, :s, "embedding", "$q") "(2.0 * (1.0 - vector.similarity.cosine(s.embedding, $q)))" ``` """ def vector_scalar(:vector_similarity, variable, prop, vec_ref) do "vector.similarity.cosine(#{variable}.#{prop}, #{vec_ref})" end def vector_scalar(:vector_cosine_distance, variable, prop, vec_ref) do "(2.0 * (1.0 - vector.similarity.cosine(#{variable}.#{prop}, #{vec_ref})))" end @doc """ Converts a node variable and labels to basic cypher node expression. ## Examples ``` iex> AshNeo4j.Cypher.node(:s, [:Actor]) "(s:Actor)" ``` """ def node(variable, labels) when is_atom(variable) and is_list(labels) do "(#{variable}:#{Enum.join(labels, ":")})" end @doc """ Converts a node variable, labels and optional property map to cypher properties string and variable prefixed parameters map. ## Examples ``` iex> AshNeo4j.Cypher.parameterized_node(:s, [:Actor]) {"(s:Actor)", %{}} iex> AshNeo4j.Cypher.parameterized_node(:s, [:Cinema, :Actor], %{name: "Bill Nighy"}) {"(s:Cinema:Actor {name: $s_name})", %{"s_name" =>"Bill Nighy"}} ``` Note: the properties map is converted to parameter names by prefixing the keys with `$`, and the original values are returned in a separate map for use as query parameters. """ def parameterized_node(variable, labels, properties \\ %{}) when is_atom(variable) and is_list(labels) and is_map(properties) do if properties == %{} do {node(variable, labels), %{}} else {property_cypher, parameters} = parameterized_properties(variable, properties) label_string = Enum.join(labels, ":") {"(#{variable}:#{label_string} #{property_cypher})", parameters} end end @doc """ Converts a node variable and optional property map to cypher properties string and variable prefixed parameters map. ## Examples ``` iex> AshNeo4j.Cypher.parameterized_properties(:s) {"{}", %{}} iex> AshNeo4j.Cypher.parameterized_properties(:s, %{name: "Bill Nighy"}) {"{name: $s_name}", %{"s_name" =>"Bill Nighy"}} ``` """ def parameterized_properties(variable, properties \\ %{}) when is_atom(variable) and is_map(properties) do parameterized_properties = properties |> Enum.map_join(", ", fn {k, _v} -> "#{quote_if_dotted(k)}: $#{variable}_#{sanitize_param(k)}" end) parameters = build_parameters(variable, properties) {"{#{parameterized_properties}}", parameters} end @doc """ Backtick-quotes a property name if it contains a dot, so that Neo4j parses it as a single property reference rather than a nested-property access. e.g. `"location.point"` → `` "`location.point`" ``. """ def quote_if_dotted(name) do s = to_string(name) if String.contains?(s, "."), do: "`#{s}`", else: s end @doc """ Rewrites dots in a name as underscores so it is safe to use as a Cypher parameter key. Neo4j parses `$foo.bar` as the parameter `$foo` followed by a `.bar` property access, so the dot has to go. """ def sanitize_param(name) do to_string(name) |> String.replace(".", "_") end @doc """ Converts a node variable and optional property map to cypher WHERE conditions and variable prefixed parameters map. ## Examples ``` iex> AshNeo4j.Cypher.parameterized_conditions(:n, %{name: "Bill Nighy"}) {"n.name = $n_name", %{"n_name" => "Bill Nighy"}} iex> AshNeo4j.Cypher.parameterized_conditions(:n, %{name: "Bill Nighy", age: 72}) {"n.name = $n_name AND n.age = $n_age", %{"n_name" => "Bill Nighy", "n_age" => 72}} ``` """ def parameterized_conditions(variable, properties \\ %{}) when is_atom(variable) and is_map(properties) do conditions = Enum.map_join(properties, " AND ", fn {k, _v} -> "#{variable}.#{k} = $#{variable}_#{k}" end) parameters = build_parameters(variable, properties) {conditions, parameters} end defp build_parameters(variable, properties) do Map.new(properties, fn {k, v} -> {"#{variable}_#{sanitize_param(k)}", v} end) end defp sandboxed_query(cypher, params) do case Process.get(:ash_neo4j_tx_stack, []) do [conn | _] -> Bolty.query(conn, cypher, params) [] -> case AshNeo4j.Sandbox.run(cypher, params) do nil -> Bolty.query(BoltyHelper.current_pool(), cypher, params) result -> result end end end @spec relationship(atom(), atom()) :: <<_::32, _::_*8>> @doc """ Converts a relationship variable, label and optional direction to cypher relationship. ## Examples ``` iex> AshNeo4j.Cypher.relationship(:r, :ACTED_IN, :outgoing) "-[r:ACTED_IN]->" iex> AshNeo4j.Cypher.relationship(:r, :ACTED_IN, :incoming) "<-[r:ACTED_IN]-" iex> AshNeo4j.Cypher.relationship(:r, :KNOWS) "-[r:KNOWS]-" ``` """ def relationship(variable, label, direction \\ nil) when is_atom(variable) and is_atom(label) and is_atom(direction) do if variable == nil do case direction do :outgoing -> "-[#{label}]->" :incoming -> "<-[#{label}]-" _ -> "-[#{label}]-" end else case direction do :outgoing -> "-[#{variable}:#{label}]->" :incoming -> "<-[#{variable}:#{label}]-" _ -> "-[#{variable}:#{label}]-" end end end def relationship(nil), do: "-[r]-" @doc """ Renders a `%Cypher.Query{}` to a `{cypher_string, params}` tuple. ## Examples ``` iex> query = %AshNeo4j.Cypher.Query{ ...> clauses: [ ...> %AshNeo4j.Cypher.Match{pattern: "(s:Actor)"}, ...> %AshNeo4j.Cypher.Return{items: ["s"]}, ...> %AshNeo4j.Cypher.Limit{value: 5} ...> ], ...> params: %{} ...> } iex> AshNeo4j.Cypher.render(query) {"MATCH (s:Actor) RETURN s LIMIT 5", %{}} iex> query = %AshNeo4j.Cypher.Query{ ...> clauses: [ ...> %AshNeo4j.Cypher.Call{ ...> branches: [ ...> "MATCH (s:Place) WHERE s.uuid = $b0_s_uuid_0 RETURN s", ...> "MATCH (s:Place) WHERE s.uuid = $b1_s_uuid_0 RETURN s" ...> ], ...> union_type: :union_all ...> }, ...> %AshNeo4j.Cypher.OptionalMatch{pattern: "(s)-[r]-(d)"}, ...> %AshNeo4j.Cypher.Return{items: ["s", "r", "d"]} ...> ], ...> params: %{"b0_s_uuid_0" => "x", "b1_s_uuid_0" => "y"} ...> } iex> {cypher, _params} = AshNeo4j.Cypher.render(query) iex> cypher "CALL { MATCH (s:Place) WHERE s.uuid = $b0_s_uuid_0 RETURN s UNION ALL MATCH (s:Place) WHERE s.uuid = $b1_s_uuid_0 RETURN s } OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d" ``` """ def render(query, opts \\ []) def render(%Query{clauses: clauses, params: params}, opts) do # The CYPHER 25 language selector may appear only once, at the very start of # a query — never inside a subquery. Branches assembled into a `CALL { … }` # block are rendered with `prefix?: false` so only the outer query carries # the prefix (#299). prefix = if Keyword.get(opts, :prefix?, true), do: cypher25_prefix(), else: "" {prefix <> Enum.map_join(clauses, " ", &render_clause/1), params} end # Dialect posture (#363): Cypher 25 is the target dialect — we emit the # `CYPHER 25 ` selector explicitly on every server that supports it, so our # (audited Cypher-25-clean) output runs under Cypher 25 there. The bare / # no-selector branch is purely the Neo4j 5.26-LTS fallback, which relies on the # server defaulting to CYPHER 5. defp cypher25_prefix do if BoltyHelper.cypher25?() do "CYPHER 25 " else warn_cypher5_sunset() "" end end # Sunset tripwire (#363): the bare fallback above assumes the server defaults to # CYPHER 5. CYPHER 5 is feature-capped from Neo4j 2026.06 and slated for removal # (late-2026.x / 2027.x TBD); a server that no longer speaks it reports # `policy.cypher_5 == false`. Reaching this with cypher_5 false means our # unprefixed Cypher now runs under an unknown server default — flag it once per # pool rather than emit silently. (Near-unreachable on real servers, since a # post-CYPHER-5 server is a Cypher 25 server and takes the prefixed branch.) defp warn_cypher5_sunset do pool = BoltyHelper.current_pool() with false <- :persistent_term.get({__MODULE__, :cypher5_warned, pool}, false), %{cypher_5: false} <- BoltyHelper.policy(pool) do Logger.warning( "AshNeo4j is emitting unprefixed Cypher (CYPHER 5 fallback) to pool #{inspect(pool)}, " <> "but the server reports no CYPHER 5 support (policy.cypher_5 == false). CYPHER 5 is " <> "being sunset (Neo4j 2026.06 feature-cap; removal TBD) — these queries now run under the " <> "server's default language. See AshNeo4j #363." ) :persistent_term.put({__MODULE__, :cypher5_warned, pool}, true) end :ok end defp render_clause(%Match{pattern: p}), do: "MATCH #{p}" defp render_clause(%OptionalMatch{pattern: p}), do: "OPTIONAL MATCH #{p}" defp render_clause(%Create{pattern: p}), do: "CREATE #{p}" defp render_clause(%Merge{pattern: p}), do: "MERGE #{p}" defp render_clause(%Where{conditions: conds}), do: "WHERE #{Enum.join(conds, " AND ")}" defp render_clause(%With{items: items}), do: "WITH #{Enum.join(items, ", ")}" defp render_clause(%Set{expression: e}), do: "SET #{e}" defp render_clause(%OnCreateSet{expression: e}), do: "ON CREATE SET #{e}" defp render_clause(%OnMatchSet{expression: e}), do: "ON MATCH SET #{e}" defp render_clause(%Remove{items: items}), do: "REMOVE #{Enum.join(items, ", ")}" defp render_clause(%Delete{items: items}), do: "DELETE #{Enum.join(items, ", ")}" defp render_clause(%DetachDelete{items: items}), do: "DETACH DELETE #{Enum.join(items, ", ")}" defp render_clause(%Return{items: items}), do: "RETURN #{Enum.join(items, ", ")}" defp render_clause(%Skip{value: n}), do: "SKIP #{n}" defp render_clause(%Limit{value: n}), do: "LIMIT #{n}" defp render_clause(%Call{branches: branches, union_type: union_type}) do joiner = case union_type do :union -> " UNION " :union_all -> " UNION ALL " end "CALL { #{Enum.join(branches, joiner)} }" end defp render_clause(%AshNeo4j.Cypher.CallSubquery{body: body}), do: "CALL { #{body} }" defp render_clause(%OrderBy{terms: terms}) do "ORDER BY " <> Enum.map_join(terms, ", ", fn {prop, :desc} -> "#{prop} DESC" {prop, _} -> "#{prop} ASC" end) end @doc """ `:ok` when the connected server supports Cypher 25 (negotiated server version ≥ 2025.06), else `{:error, %AshNeo4j.Error.RequiresCypher25{}}`. Use at the top of any function that emits Cypher 25-only syntax and thread the error up — a data layer returns it, never raises. """ @spec require_cypher25() :: :ok | {:error, struct()} def require_cypher25() do if BoltyHelper.cypher25?() do :ok else {:error, AshNeo4j.Error.RequiresCypher25.exception([])} end end @doc """ A **dynamic label/type** fragment `:$(expr)` (Neo4j ≥ 5.26) — the runtime-resolved counterpart to a static `:Label` in a node pattern `(n:$(expr))` or a relationship pattern `-[r:$(expr)]->` (#339). `expr` is any Cypher expression yielding the label/type: a parameter token (`"$label"`), a property (`"n.kind"`), or — for multiple labels in `CREATE` — a list-valued parameter (`"$labels"`). `expr` must be a server-side Cypher expression, never an interpolated literal — that is the injection-safe form (the value is bound, not string-built). Gate emission with `require_dynamic_labels/0`. ## Examples ``` iex> AshNeo4j.Cypher.dynamic_label("$label") ":$($label)" iex> AshNeo4j.Cypher.dynamic_label("n.kind") ":$(n.kind)" ``` """ @spec dynamic_label(binary()) :: binary() def dynamic_label(expr) when is_binary(expr), do: ":$(#{expr})" @doc """ `:ok` when the connected server supports dynamic labels/types in **pattern position** (`(n:$(expr))`, `-[r:$(expr)]->` in `MATCH`/`CREATE`/`MERGE`; negotiated server version ≥ 5.26), else `{:error, %AshNeo4j.Error.RequiresDynamicLabels{}}`. This is the **server-feature axis** (plain `CYPHER 5`), distinct from `require_cypher25/0`. Use at the top of any function that emits `dynamic_label/1` and thread the error up — a data layer returns it, never raises. > #### WHERE-predicate label filtering is a *finer* gate {: .warning} > The `WHERE n:$(expr)` predicate form is **not** covered by this check: it > fails on 5.26 (`dynamic_labels: true`) and only works on later servers > (verified ≥ 2026.05). `dynamic_labels?/0` / `policy().dynamic_labels` guarantee > the pattern form only — matching bolty's flag semantics. A label-filter > consumer must establish its own server gate (#339; capability-granularity > question raised as diffo-dev/bolty#53). """ @spec require_dynamic_labels() :: :ok | {:error, struct()} def require_dynamic_labels() do if BoltyHelper.dynamic_labels?() do :ok else {:error, AshNeo4j.Error.RequiresDynamicLabels.exception([])} end end @doc """ Runs some cypher ## Examples ``` iex> cypher = "CREATE (n:Actor {name: 'Bill Nighy', born: 1949, bafta_winner: true}) RETURN n" iex> {result, _} = AshNeo4j.Cypher.run(cypher) iex> result :ok iex> cypher = "MATCH (n:Actor {name: $name}) RETURN n" iex> params = %{name: "Bill Nighy"} iex> {result, _} = AshNeo4j.Cypher.run(cypher, params) iex> result :ok ``` """ def run(%Query{} = query) do {cypher, params} = render(query) run(cypher, params) end def run(cypher, params \\ %{}) when is_bitstring(cypher) do cypher = cypher25_prefix() <> cypher Logger.debug("AshNeo4j.Cypher: run(#{cypher}, #{inspect(params)})") start = System.monotonic_time() bolty_result = sandboxed_query(cypher, params) # Telemetry seam (#60): emit every query with its rendered Cypher, params and # raw bolty result. No handlers are attached by default, so this is ~free; it # lets opt-in tooling (e.g. `AshNeo4j.Mermaid.tap/0`) observe the last result # without `Cypher.run` knowing anything about its consumers. :telemetry.execute( [:ash_neo4j, :query], %{duration: System.monotonic_time() - start}, %{cypher: cypher, params: params, result: bolty_result} ) if elem(bolty_result, 0) == :ok do Logger.debug("AshNeo4j.Cypher: run result #{inspect(elem(bolty_result, 1).results)}") end bolty_result end def run_expecting_deletions(%Query{} = query) do {cypher, params} = render(query) run_expecting_deletions(cypher, params) end def run_expecting_deletions(cypher, params \\ %{}) when is_bitstring(cypher) do cypher = cypher25_prefix() <> cypher Logger.debug("AshNeo4j.Cypher: run_expecting_deletions(#{cypher})") bolty_result = sandboxed_query(cypher, params) if elem(bolty_result, 0) == :ok do response = elem(bolty_result, 1) deleted_nodes = case response.stats do [] -> 0 %{} -> Map.get(response.stats, "nodes-deleted", 0) end if deleted_nodes == 0 do # Expected control flow, not an error: a preservation guard held the node, # or an optimistic-lock destroy missed. The caller disambiguates this into # StaleRecord / Unavailable, so trace it at :debug rather than :error (#373). # `:nothing_deleted` is an internal sentinel (never escapes to Ash), #372. Logger.debug("AshNeo4j.Cypher: nothing deleted") {:error, :nothing_deleted} else Logger.debug("AshNeo4j.Cypher: run_expecting_deletions deleted #{deleted_nodes} nodes") bolty_result end else bolty_result end end end