AshNeo4j.Cypher.Query (AshNeo4j v0.10.1)

Copy Markdown View Source

Typed representation of a Cypher query, and builders for constructing common patterns.

The struct holds an ordered list of typed clause structs and a params map. Callers build a query via the builder functions, then pass it to AshNeo4j.Cypher.render/1 or AshNeo4j.Cypher.run/1.

Clause structs

Read: Match, OptionalMatch, Where, With, Return, OrderBy, Skip, Limit Write: Create, Merge, Set, Remove, Delete, DetachDelete

Summary

Types

A single property filter condition for node_read_filtered/2: {property, operator_atom, value, case_insensitive?}

t()

Functions

Appends a LIMIT clause. No-op when n is nil.

Appends an ORDER BY clause. No-op when terms is empty.

Appends a SKIP clause. No-op when n is nil or 0.

Per-record aggregate — returns one row per source node with the aggregate value.

Total aggregate — returns a single row with the aggregate value across all source nodes.

MATCH (s:L1:L2) [WHERE <conditions>] RETURN s — a single combination-query branch, sized to fit inside a CALL { … } block.

Same as branch_node_read/3 but returns just the Neo4j internal id of s (as sid) instead of the node itself. Used to cheaply materialise the id set per branch in the in-memory orchestration path for INTERSECT / EXCEPT combination queries.

Bulk destroy (#361): MATCH (n:L) WHERE <filter> AND NOT <guard…> DETACH DELETE n. Deletes every node matching conditions (the pushed-down query filter) that isn't protected by a preservation guard — guarded nodes are skipped, not errored ("delete what is safe"). When return?, the node's properties/id/ labels are captured in a WITH before the delete (a returned deleted node has empty properties) and returned for record reconstruction.

Wraps a list of branch queries (built via branch_node_read/3) in a CALL { … UNION/UNION ALL … } block followed by the outer OPTIONAL MATCH enrichment and RETURN s, r, d.

CREATE (n:L1:L2 {props}) RETURN n

Single guarded + filtered destroy (#361): MATCH (n:L {id}) WHERE <filter> AND NOT <guard…> DETACH DELETE n. The changeset.filter optimistic-lock conditions are ANDed with the preservation guards. Run via run_expecting_deletions/1: zero deletions ⇒ the caller disambiguates filter-miss (StaleRecord) from guard (Unavailable) with node_matching/3.

MATCH (n:L1:L2 {props}) DETACH DELETE n

MATCH (n:L1:L2 {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n

MATCH (n:Labels {props}) RETURN n

MERGE (n:Label {props}) RETURN n

Merges params into the query's param map. Later keys win.

MATCH (n:L {id}) WHERE <filter> RETURN n — the optimistic-lock existence check (no guard) used to disambiguate a zero-deletion delete_node_filtered/4 (#361).

MATCH (s:L1:L2) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d

MATCH (s:L1:L2) WHERE id(s) IN $ids OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d.

MATCH (s:L1:L2) WHERE <conditions> OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d

MATCH (s:L1:L2) WHERE <where> OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d with a pre-rendered WHERE string and its params — the fragment(...) escape hatch (#33), where the condition is author-supplied Cypher rather than a derived predicate.

MATCH (n:L1:L2 {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d

Applies SKIP/LIMIT pagination (and the ORDER BY that makes them deterministic) to the distinct source nodes s, before the edge-expansion step of a node read.

MATCH (s:SrcLabel {s_props}) [WHERE guard] OPTIONAL MATCH (d:DestLabel {d_props}) MERGE (s)-[r:EDGE]->(d) RETURN s, r, d

Relates two nodes, removing existing edges from source AND to destination.

Relates two nodes, first removing any existing edge of the same type pointing to the destination.

Relates two nodes, first removing any existing edge of the same type from the source.

Related-nodes query — returns one row per (source, destination) pair for expression-based aggregates that need full destination records for Elixir-side evaluation.

MATCH (s:SrcLabels)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop <op> $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0

Field aggregate over a traversal's reached set (#338) compared against a value.

Existence / cardinality of a traversal's reached set (#334) — a WHERE predicate on the source, not a field comparison on the reached node.

Multi-hop traversal filter (#321) — generalises relationship_read/7 to a path.

MATCH (s:SrcLabel {s_props})-[r:EDGE]->(d:DestLabel {d_props}) [WHERE guard] DELETE r RETURN s, d

MATCH (n:L1:L2 {match_props}) [WHERE guard] SET n += {set_props} [, n.x = <expr>] REMOVE n.p1, n.p2 RETURN n

MATCH (n:L1:L2 {match_props}) SET n:Add1:Add2 REMOVE n:Rem1:Rem2 RETURN n

Atomic upsert (#379): MERGE (n:L1:L2 {merge_props}) [ON CREATE SET n += {create_props}[, n:L3:L4]] [ON MATCH SET n += {match_props}] RETURN n.

Types

Functions

add_limit(query, n)

@spec add_limit(t(), pos_integer() | nil) :: t()

Appends a LIMIT clause. No-op when n is nil.

add_order_by(query, terms)

@spec add_order_by(t(), [{String.t(), :asc | :desc}]) :: t()

Appends an ORDER BY clause. No-op when terms is empty.

Each term is {order_expression, :asc | :desc} where order_expression is a fully-formed Cypher expression — e.g. "s.name" for a plain property or "vector.similarity.cosine(s.embedding, $q)" for an expression sort. The caller (AshNeo4j.QueryHelper's sort handling) is responsible for the s. prefix and for merging any referenced params via merge_params/2.

add_skip(query, n)

@spec add_skip(t(), non_neg_integer() | nil) :: t()

Appends a SKIP clause. No-op when n is nil or 0.

aggregate_per_record(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false, dest_conditions \\ [])

@spec aggregate_per_record(
  atom() | [atom()],
  atom(),
  [any()],
  [{atom(), atom(), atom()}],
  atom(),
  atom() | nil,
  atom(),
  boolean(),
  [{String.t(), any()}]
) :: t()

Per-record aggregate — returns one row per source node with the aggregate value.

MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)<path>(d) RETURN s.pk AS source_id, agg_fn AS name

path_segments is a list of {edge_label, direction, dest_label} tuples describing the traversal from source to the node being aggregated.

aggregate_total(source_label, pk_field, ids, path_segments, kind, field, name, uniq? \\ false, dest_conditions \\ [])

@spec aggregate_total(
  atom() | [atom()],
  atom(),
  [any()],
  [{atom(), atom(), atom()}],
  atom(),
  atom() | nil,
  atom(),
  boolean(),
  [{String.t(), any()}]
) :: t()

Total aggregate — returns a single row with the aggregate value across all source nodes.

MATCH (s:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)<path>(d) RETURN agg_fn AS name

branch_node_read(label, conditions \\ [], opts \\ [])

@spec branch_node_read(atom() | [atom()], [condition()], keyword()) :: t()

MATCH (s:L1:L2) [WHERE <conditions>] RETURN s — a single combination-query branch, sized to fit inside a CALL { … } block.

No OPTIONAL MATCH (the outer combination query owns enrichment) and only the s column is returned (Cypher's UNION/UNION ALL requires identical column shapes across branches).

Supports param_prefix: per branch — pass distinct prefixes for distinct branches so their param keys (b0_s_name_0 vs b1_s_name_0) don't collide when merged.

Examples

iex> q = AshNeo4j.Cypher.Query.branch_node_read(:Place)
iex> {cypher, _} = AshNeo4j.Cypher.render(q)
iex> cypher
"MATCH (s:Place) RETURN s"

iex> q = AshNeo4j.Cypher.Query.branch_node_read(:Place, [{"name", :==, "Sydney", false}], param_prefix: "b0_")
iex> {cypher, params} = AshNeo4j.Cypher.render(q)
iex> cypher
"MATCH (s:Place) WHERE s.name = $b0_s_name_0 RETURN s"
iex> params
%{"b0_s_name_0" => "Sydney"}

branch_node_read_ids(label, conditions \\ [], opts \\ [])

@spec branch_node_read_ids(atom() | [atom()], [condition()], keyword()) :: t()

Same as branch_node_read/3 but returns just the Neo4j internal id of s (as sid) instead of the node itself. Used to cheaply materialise the id set per branch in the in-memory orchestration path for INTERSECT / EXCEPT combination queries.

Examples

iex> q = AshNeo4j.Cypher.Query.branch_node_read_ids(:Place, [{"name", :==, "Sydney", false}], param_prefix: "b0_")
iex> {cypher, _} = AshNeo4j.Cypher.render(q)
iex> cypher
"MATCH (s:Place) WHERE s.name = $b0_s_name_0 RETURN id(s) AS sid"

bulk_detach_delete(label, conditions, guards, return?)

@spec bulk_detach_delete(atom() | [atom()], list(), list(), boolean()) :: t()

Bulk destroy (#361): MATCH (n:L) WHERE <filter> AND NOT <guard…> DETACH DELETE n. Deletes every node matching conditions (the pushed-down query filter) that isn't protected by a preservation guard — guarded nodes are skipped, not errored ("delete what is safe"). When return?, the node's properties/id/ labels are captured in a WITH before the delete (a returned deleted node has empty properties) and returned for record reconstruction.

combination_block(branches, opts \\ [])

@spec combination_block(
  [t()],
  keyword()
) :: t()

Wraps a list of branch queries (built via branch_node_read/3) in a CALL { … UNION/UNION ALL … } block followed by the outer OPTIONAL MATCH enrichment and RETURN s, r, d.

Branch params are merged into the outer query's params map. Branches are rendered to Cypher strings before being placed in the Call clause.

Opts:

  • :union_type:union or :union_all (default :union_all)

Examples

iex> b0 = AshNeo4j.Cypher.Query.branch_node_read(:Place, [{"name", :==, "Sydney", false}], param_prefix: "b0_")
iex> b1 = AshNeo4j.Cypher.Query.branch_node_read(:Place, [{"name", :==, "Melbourne", false}], param_prefix: "b1_")
iex> q = AshNeo4j.Cypher.Query.combination_block([b0, b1])
iex> {cypher, params} = AshNeo4j.Cypher.render(q)
iex> cypher
"CALL { MATCH (s:Place) WHERE s.name = $b0_s_name_0 RETURN s UNION ALL MATCH (s:Place) WHERE s.name = $b1_s_name_0 RETURN s } WITH s OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d"
iex> params
%{"b0_s_name_0" => "Sydney", "b1_s_name_0" => "Melbourne"}

create_node(labels, properties)

@spec create_node(atom() | [atom()], map()) :: t()

CREATE (n:L1:L2 {props}) RETURN n

delete_node_filtered(label, id_props, filter_conditions, guards)

@spec delete_node_filtered(atom() | [atom()], map(), list(), list()) :: t()

Single guarded + filtered destroy (#361): MATCH (n:L {id}) WHERE <filter> AND NOT <guard…> DETACH DELETE n. The changeset.filter optimistic-lock conditions are ANDed with the preservation guards. Run via run_expecting_deletions/1: zero deletions ⇒ the caller disambiguates filter-miss (StaleRecord) from guard (Unavailable) with node_matching/3.

delete_nodes(label, properties \\ %{})

@spec delete_nodes(atom() | [atom()], map()) :: t()

MATCH (n:L1:L2 {props}) DETACH DELETE n

delete_nodes_guarded(label, properties, guards)

@spec delete_nodes_guarded(atom() | [atom()], map(), list()) :: t()

MATCH (n:L1:L2 {props}) WHERE NOT guard1 AND NOT guard2 DETACH DELETE n

guards is a list of {edge_label, direction, dest_label} tuples. Falls back to delete_nodes/2 when guards is empty.

match_nodes(labels, properties \\ %{})

@spec match_nodes(atom() | [atom()], map()) :: t()

MATCH (n:Labels {props}) RETURN n

merge_node(label, properties)

@spec merge_node(atom(), map()) :: t()

MERGE (n:Label {props}) RETURN n

merge_params(query, params)

@spec merge_params(t(), map()) :: t()

Merges params into the query's param map. Later keys win.

node_matching(label, id_props, filter_conditions)

@spec node_matching(atom() | [atom()], map(), list()) :: t()

MATCH (n:L {id}) WHERE <filter> RETURN n — the optimistic-lock existence check (no guard) used to disambiguate a zero-deletion delete_node_filtered/4 (#361).

node_read(label)

@spec node_read(atom() | [atom()]) :: t()

MATCH (s:L1:L2) OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d

node_read_by_ids(label, ids)

@spec node_read_by_ids(atom() | [atom()], [integer()]) :: t()

MATCH (s:L1:L2) WHERE id(s) IN $ids OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d.

Used as the final read after in-memory combination orchestration computes the keep-set of node ids.

node_read_filtered(label, conditions, opts \\ [])

@spec node_read_filtered(atom() | [atom()], [condition()], keyword()) :: t()

MATCH (s:L1:L2) WHERE <conditions> OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d

Returns node_read/1 when conditions is empty.

node_read_fragment(label, where, params)

@spec node_read_fragment(atom() | [atom()], String.t(), map()) :: t()

MATCH (s:L1:L2) WHERE <where> OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d with a pre-rendered WHERE string and its params — the fragment(...) escape hatch (#33), where the condition is author-supplied Cypher rather than a derived predicate.

node_read_with_properties(label, properties)

@spec node_read_with_properties(atom() | [atom()], map()) :: t()

MATCH (n:L1:L2 {props}) OPTIONAL MATCH (n)-[r]-(d) RETURN n, r, d

Like node_read/1 but matches by properties in the MATCH pattern (not a WHERE clause).

paginate_nodes(query, order_terms, skip, limit)

@spec paginate_nodes(
  t(),
  [{String.t(), :asc | :desc}],
  non_neg_integer() | nil,
  pos_integer() | nil
) ::
  t()

Applies SKIP/LIMIT pagination (and the ORDER BY that makes them deterministic) to the distinct source nodes s, before the edge-expansion step of a node read.

The node-read shapes (node_read/1, node_read_filtered/3, node_read_by_ids/2, relationship_read/7, and the combination final read) all expand one row per edge via the clause immediately preceding the RETURN (OPTIONAL MATCH (s)-[r]-(d) etc.). A trailing SKIP/LIMIT would therefore truncate edges, not nodes — dropping relationships (e.g. a belongs_to edge) from nodes that have more edges than the limit. This inserts WITH s [ORDER BY …] [SKIP …] [LIMIT …] ahead of that expansion so pagination is scoped to nodes.

No-op when there is no skip and no limit (a sort alone is handled by the trailing add_order_by/2, which orders the per-edge rows by node property and so keeps each node's rows grouped).

relate(src_label, src_props, dest_label, dest_props, edge_label, direction, opts \\ [])

@spec relate(
  atom() | [atom()],
  map(),
  atom() | [atom()],
  map(),
  atom(),
  atom(),
  keyword()
) :: t()

MATCH (s:SrcLabel {s_props}) [WHERE guard] OPTIONAL MATCH (d:DestLabel {d_props}) MERGE (s)-[r:EDGE]->(d) RETURN s, r, d

opts[:guard] is a changeset.filter condition list (#368) gating the attach on the live source node; a guard miss yields zero rows ⇒ the caller raises StaleRecord.

relate_unrelating_both(src_label, src_props, dest_label, dest_props, edge_label, direction, opts \\ [])

@spec relate_unrelating_both(
  atom() | [atom()],
  map(),
  atom(),
  map(),
  atom(),
  atom(),
  keyword()
) :: t()

Relates two nodes, removing existing edges from source AND to destination.

MATCH (s:SrcLabel {s_props}) WITH s
OPTIONAL MATCH (s)-[r0:EDGE]->(d:DestLabel {d_props}) DELETE r0 WITH s
OPTIONAL MATCH (d:DestLabel {d_props}) WITH s, d
OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s DELETE r0
WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d

relate_unrelating_destination(src_label, src_props, dest_label, dest_props, edge_label, direction, opts \\ [])

@spec relate_unrelating_destination(
  atom() | [atom()],
  map(),
  atom(),
  map(),
  atom(),
  atom(),
  keyword()
) ::
  t()

Relates two nodes, first removing any existing edge of the same type pointing to the destination.

MATCH (s:SrcLabel {s_props}) OPTIONAL MATCH (d:DestLabel {d_props})
WITH s, d OPTIONAL MATCH (s0:SrcLabel)-[r0:EDGE]->(d) WHERE s0 <> s
DELETE r0 WITH s, d MERGE (s)-[r:EDGE]->(d) RETURN s, r, d

relate_unrelating_source(src_label, src_props, dest_label, dest_props, edge_label, direction, opts \\ [])

@spec relate_unrelating_source(
  atom() | [atom()],
  map(),
  atom(),
  map(),
  atom(),
  atom(),
  keyword()
) ::
  t()

Relates two nodes, first removing any existing edge of the same type from the source.

MATCH (s:SrcLabel {s_props})
WITH s OPTIONAL MATCH (s)-[r0:EDGE]->(d0:DestLabel)
DELETE r0 WITH s MATCH (d:DestLabel {d_props})
MERGE (s)-[r:EDGE]->(d) RETURN s, r, d

relationship_read(src_label, edge_label, direction, dest_label, dest_property, operator, value)

@spec relationship_read(
  atom() | [atom()],
  atom(),
  atom(),
  atom(),
  String.t(),
  atom(),
  any()
) :: t()

MATCH (s:SrcLabels)-[r:EdgeLabel]-(d:DestLabel) WHERE d.prop <op> $param WITH s MATCH (s)-[r0]-(d0) RETURN s, r0, d0

traversal_aggregate_read(src_label, path_segments, arg)

@spec traversal_aggregate_read(
  atom() | [atom()],
  [{atom(), atom(), atom() | nil}],
  tuple()
) :: t()

Field aggregate over a traversal's reached set (#338) compared against a value.

No native MIN {}/MAX {}/AVG {}/SUM {} subquery expression exists, so this uses a scoped CALL that aggregates the reached field, then filters the scalar — plain Cypher 5 (the WITH s import form, not CALL (s) {}):

MATCH (s:Src) CALL { WITH s MATCH (s)<path>(d) RETURN min(d.p) AS agg_v } WITH s, agg_v WHERE agg_v <op> $v OPTIONAL MATCH (s)-[r]-(d0) RETURN s, r, d0

agg is {agg_fn, prop, op, value} where agg_fn ∈ [:min, :max, :avg, :sum] and prop is the already-resolved reached-node property name.

Empty-set semantics follow Neo4j's aggregating functions and differ by agg_fn: min/max/avg over a no-reach source yield null (so null <op> $v drops it), while sum yields 0 (like count) and can still match a comparison.

traversal_predicate_read(src_label, path_segments, agg)

@spec traversal_predicate_read(
  atom() | [atom()],
  [{atom(), atom(), atom() | nil}],
  tuple()
) :: t()

Existence / cardinality of a traversal's reached set (#334) — a WHERE predicate on the source, not a field comparison on the reached node.

MATCH (s:Src) WHERE EXISTS { MATCH (s)<path>(d) } OPTIONAL MATCH (s)-[r]-(d0) RETURN s, r, d0 MATCH (s:Src) WHERE COUNT { MATCH (s)<path>(d) RETURN DISTINCT d } > $n OPTIONAL MATCH (s)-[r]-(d0) RETURN s, r, d0

agg selects the predicate:

  • {:exists, true}EXISTS { … } (short-circuits on first match)
  • {:exists, false}NOT EXISTS { … } (membership exclusion)
  • {:count, op, n}COUNT { … RETURN DISTINCT d } <op> $n (distinct reached nodes)

The source is filtered directly (no per-path row fan-out), and enrichment uses OPTIONAL MATCH so a NOT EXISTS source with no other edges still returns. Needs no reached-resource typing, so it composes over reverse chains too.

traversal_read(src_label, path_segments, conditions)

@spec traversal_read(atom() | [atom()], [{atom(), atom(), atom() | nil}], [tuple()]) ::
  t()

Multi-hop traversal filter (#321) — generalises relationship_read/7 to a path.

MATCH (s:Src)<path>(d) WHERE <reached conditions> WITH DISTINCT s MATCH (s)-[r0]-(d0) RETURN s, r0, d0

path_segments is [{edge_label, direction, dest_label}] (dest_label may be nil for an unlabelled hop); the reached node binds as d. conditions are the same {prop, op, val, ci?} tuples the property/spatial/vector path uses — rendered against d via build_conditions/3, so a reached-node field comparison, st_dwithin, st_distance or vector predicate all compose here. WITH DISTINCT s collapses a source matched via multiple paths to one row.

unrelate(src_label, src_props, dest_label, dest_props, edge_label, direction, opts \\ [])

@spec unrelate(atom() | [atom()], map(), atom(), map(), atom(), atom(), keyword()) ::
  t()

MATCH (s:SrcLabel {s_props})-[r:EDGE]->(d:DestLabel {d_props}) [WHERE guard] DELETE r RETURN s, d

opts[:guard] is a changeset.filter condition list (#368) gating the detach on the live source node; a guard miss yields zero rows ⇒ the caller raises StaleRecord.

update_node(label, match_props, set_props, remove_props \\ [], opts \\ [])

@spec update_node(atom() | [atom()], map(), map(), [atom()], keyword()) :: t()

MATCH (n:L1:L2 {match_props}) [WHERE guard] SET n += {set_props} [, n.x = <expr>] REMOVE n.p1, n.p2 RETURN n

Handles all combinations of empty/non-empty set_props and remove_props. opts:

  • :guardchangeset.filter conditions (#361); rendered as a WHERE between the MATCH and SET. Zero matched rows ⇒ the caller raises StaleRecord.
  • :atomics{set_expressions, params} for changeset.atomics (#361): live-node SET n.<prop> = <expr> clauses (already rendered against :n).

update_node_labels(label, match_props, add_labels, remove_labels \\ [])

@spec update_node_labels(atom() | [atom()], map(), [atom()], [atom()]) :: t()

MATCH (n:L1:L2 {match_props}) SET n:Add1:Add2 REMOVE n:Rem1:Rem2 RETURN n

Adds and/or removes node labels on a matched node. A node's label set drives AshNeo4j.worlds/1, so this moves a node between worlds — handy in tests to place a node in (or strip it of) a resolvable world after an Ash create. Handles all combinations of empty/non-empty add and remove lists.

upsert_node(label, merge_props, create_props, match_props, create_labels \\ [])

@spec upsert_node(atom() | [atom()], map(), map(), map(), [atom()]) :: t()

Atomic upsert (#379): MERGE (n:L1:L2 {merge_props}) [ON CREATE SET n += {create_props}[, n:L3:L4]] [ON MATCH SET n += {match_props}] RETURN n.

merge_props are the upsert identity's properties (matched-or-created on); create_props are the rest of the node, set only when a new node is created; match_props are the set_on_upsert fields, set only when an existing node is matched. With the identity's uniqueness constraint (#20) this is a race-free, single-statement upsert. Param prefixes are distinct (n_/nc_/nm_) so the three property sets on the :n alias don't collide.

create_labels (#392) are extra labels — typically the fragment/base-type and domain-fragment labels in all_labels beyond the [domain, module] label_pair used to MERGE — added with ON CREATE SET n:L3:L4 so an upserted node carries the same full label set a plain create writes. The MERGE matches on label_pair (a label subset still matches a node already carrying the extra labels), so existing nodes are located correctly and only newly created ones get the labels set.