# `AshNeo4j.Cypher.Query`
[🔗](https://github.com/diffo-dev/ash_neo4j/blob/v0.10.1/lib/cypher/query.ex#L129)

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`

# `clause`

```elixir
@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()
```

# `condition`

```elixir
@type condition() :: {String.t(), atom(), any(), boolean()}
```

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

# `t`

```elixir
@type t() :: %AshNeo4j.Cypher.Query{clauses: [clause()], params: map()}
```

# `add_limit`

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

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

# `add_order_by`

```elixir
@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`

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

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

# `aggregate_per_record`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `delete_node_filtered`

```elixir
@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`

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

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

# `delete_nodes_guarded`

```elixir
@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`

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

`MATCH (n:Labels {props}) RETURN n`

# `merge_node`

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

`MERGE (n:Label {props}) RETURN n`

# `merge_params`

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

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

# `node_matching`

```elixir
@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`

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

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

# `node_read_by_ids`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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

# `related_nodes`

```elixir
@spec related_nodes(atom() | [atom()], atom(), [any()], [{atom(), atom(), atom()}]) ::
  t()
```

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:L1:L2) WHERE s.pk IN $agg_ids OPTIONAL MATCH (s)<path>(d) RETURN s.pk AS source_id, d AS dest_node`

# `relationship_read`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`:

  * `:guard` — `changeset.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`

```elixir
@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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
