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?}
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
@type clause() :: AshNeo4j.Cypher.Match.t() | AshNeo4j.Cypher.OptionalMatch.t() | AshNeo4j.Cypher.Create.t() | AshNeo4j.Cypher.Merge.t() | AshNeo4j.Cypher.Where.t() | AshNeo4j.Cypher.With.t() | AshNeo4j.Cypher.Set.t() | AshNeo4j.Cypher.OnCreateSet.t() | AshNeo4j.Cypher.OnMatchSet.t() | AshNeo4j.Cypher.Remove.t() | AshNeo4j.Cypher.Delete.t() | AshNeo4j.Cypher.DetachDelete.t() | AshNeo4j.Cypher.Return.t() | AshNeo4j.Cypher.OrderBy.t() | AshNeo4j.Cypher.Skip.t() | AshNeo4j.Cypher.Limit.t() | AshNeo4j.Cypher.Call.t() | AshNeo4j.Cypher.CallSubquery.t()
A single property filter condition for node_read_filtered/2:
{property, operator_atom, value, case_insensitive?}
Functions
@spec add_limit(t(), pos_integer() | nil) :: t()
Appends a LIMIT clause. No-op when n is nil.
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.
@spec add_skip(t(), non_neg_integer() | nil) :: t()
Appends a SKIP clause. No-op when n is nil or 0.
@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.
@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
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"}
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 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.
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—:unionor: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 (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
guards is a list of {edge_label, direction, dest_label} tuples.
Falls back to delete_nodes/2 when guards is empty.
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.
Used as the final read after in-memory combination orchestration computes the keep-set of node ids.
MATCH (s:L1:L2) WHERE <conditions> OPTIONAL MATCH (s)-[r]-(d) RETURN s, r, d
Returns node_read/1 when conditions is empty.
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
Like node_read/1 but matches by properties in the MATCH pattern (not a WHERE clause).
@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).
@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.
@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
@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
@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
@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
@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.
@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.
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.
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.
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:
:guard—changeset.filterconditions (#361); rendered as aWHEREbetween theMATCHandSET. Zero matched rows ⇒ the caller raisesStaleRecord.:atomics—{set_expressions, params}forchangeset.atomics(#361): live-nodeSET n.<prop> = <expr>clauses (already rendered against:n).
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.
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.