diff --git a/.agents/skills/neo4j-cypher-skill/README.md b/.agents/skills/neo4j-cypher-skill/README.md new file mode 100644 index 0000000000..5e0ee42cdd --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/README.md @@ -0,0 +1,60 @@ +# neo4j-cypher-skill + +Generates, optimizes, and validates Cypher 25 queries for Neo4j 2025.x and 2026.x. + +## Topics covered + +**Query writing** — reads, writes, subqueries, batch operations, LOAD CSV, schema inspection, EXPLAIN/PROFILE validation + +**Patterns** — MATCH, OPTIONAL MATCH, WITH, UNION, MERGE (constrained-key rules), FOREACH, UNWIND, CALL IN TRANSACTIONS + +**Subqueries** — `EXISTS {}`, `COUNT {}`, `COLLECT {}`, `CALL (x) { }`, `OPTIONAL CALL` + +**Path expressions** — Quantified Path Expressions (QPEs), match modes (`DIFFERENT RELATIONSHIPS`, `REPEATABLE ELEMENTS`), path selectors (`SHORTEST 1`, `ALL SHORTEST`) + +**Search** — vector search (`SEARCH` clause 2026.01+, procedure fallback for 2025.x), fulltext (`db.index.fulltext`) + +**Schema** — `db.schema.visualization`, `SHOW INDEXES/CONSTRAINTS/PROCEDURES`, `apoc.meta.schema()` (preferred when APOC available) + +**Performance** — Eager operator detection and fixes, parallel runtime, index hints, anti-patterns by severity + +**Language features** — dynamic labels/properties (2025.01), type predicates (`IS :: INTEGER NOT NULL`), `OrNull` casting, `coll.sort()`, `btrim()`, date/time arithmetic, null handling, 40+ syntax traps + +## Version coverage + +Defaults to 2025.01-safe features. Items new in 2025.x are annotated `[2025.01]` in the reference files; 2026.x items `[2026.01]`. + +## Not covered + +- Driver migration → `neo4j-migration-skill` +- DB administration → `neo4j-cli-tools-skill` + +## Reference files + +Loaded on demand — not bundled into the main skill context: + +| File | Contents | +|---|---| +| [`references/cypher-syntax.md`](references/cypher-syntax.md) | Full syntax reference: clauses, patterns, functions. Items introduced in 2025.x annotated `[2025.01]`; 2026.x items `[2026.01]`; older deprecated forms annotated `[replaces X]` | +| [`references/syntax-traps.md`](references/syntax-traps.md) | 40+ table of invalid → correct Cypher — SQL habits and pre-2025 syntax | +| [`references/performance.md`](references/performance.md) | Anti-patterns with severity levels, text vs fulltext index comparison, Eager operator triggers and fixes | + +## Not covered + +- Driver migration or version upgrade → `neo4j-migration-skill` +- Database administration (users, config, backups) → `neo4j-cli-tools-skill` +- GQL clauses: `LET`, `FINISH`, `FILTER`, and `INSERT` are valid in Cypher 25 (introduced via GQL conformance, mostly in Neo4j 2025.06); not available on older versions + +## Related skills + +| Skill | Purpose | +|---|---| +| `neo4j-getting-started-skill` | Zero-to-app: provision, model, load, explore, build | +| `neo4j-migration-skill` | Upgrade Cypher syntax and drivers across major versions | +| `neo4j-cli-tools-skill` | DB administration via `neo4j-admin`, `cypher-shell`, Aura CLI | + +## Install + +```bash +npx skills add https://github.com/neo4j-contrib/neo4j-skills --skill neo4j-cypher-skill +``` diff --git a/.agents/skills/neo4j-cypher-skill/SKILL.md b/.agents/skills/neo4j-cypher-skill/SKILL.md new file mode 100644 index 0000000000..7b08b1f767 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/SKILL.md @@ -0,0 +1,400 @@ +--- +name: neo4j-cypher-skill +description: Generates, optimizes, and validates Cypher 25 queries for Neo4j 2025.x and 2026.x. + Use when writing new Cypher queries, optimizing slow queries, graph pattern matching, vector + or fulltext search, subqueries, or batch writes. Covers MATCH, MERGE, CREATE, WITH, RETURN, + CALL, UNWIND, FOREACH, LOAD CSV, SEARCH, expressions, functions, indexes, and subqueries. + Does NOT handle driver migration or API changes — use neo4j-migration-skill. + Does NOT cover DB administration or server ops — use neo4j-cli-tools-skill. +compatibility: Neo4j >= 2025.01 (safe baseline); Cypher 25 +version: 1.0.1 +--- + +## When to Use +- Writing, optimizing, or debugging Cypher queries +- Graph pattern matching, QPEs, variable-length paths +- Vector/fulltext search, subqueries, batch writes, LOAD CSV + +## When NOT to Use +- **Driver migration/API changes** → `neo4j-migration-skill` +- **DB admin** (users, config, backups) → `neo4j-cli-tools-skill` +- **Hybrid search that combines vector with fulltext or other ranked sources** → `neo4j-vector-index-skill` + +GQL conformance note: `LET`, `FINISH`, `FILTER`, and `INSERT` are valid Cypher 25 clauses (introduced via GQL conformance, mostly in Neo4j 2025.06). On older versions, fall back to `WITH` / (omit RETURN) / `WHERE` / `CREATE`. `INSERT` requires `&`-separated multi-labels and does not support dynamic labels/types. + +--- + +## Pre-flight + +| ? | Known | Unknown | +|---|---|---| +| `-schema.json` found in project | Use it directly — skip live inspection | — | +| Schema (from context or live DB) | Use directly | Run Schema-First Protocol | +| Neo4j version | Use version features | Default to 2025.01 safe set | +| Executing (not generating)? | Use EXPLAIN + write gate | State query is unvalidated | + +Schema unknown + no tool → produce non-executable sketch outside a code block: +``` +( {: $value})-[:]->() +``` +Never fill guessed names — realistic guesses get copied blindly. + +--- + +## Defaults — apply every query + +1. `CYPHER 25` — first token; never repeat after `UNION` or inside subqueries +2. Schema first — inspect before writing; if schema in prompt, use it directly +3. `MERGE` on constrained key only; rel `MERGE` on already-bound endpoints only +4. Label-free `MATCH (n)` forbidden unless bound or followed by `WHERE n:$($label)` +5. `LIMIT 25` default on all exploratory reads; push `WITH n LIMIT` before high-cardinality operations (variable-length traversals, fan-out MATCH, Cartesian products) +6. Comments: `//` only — `--` is SQL, invalid +7. `REPEATABLE ELEMENTS` / `DIFFERENT RELATIONSHIPS` go after `MATCH`, not end of pattern +8. `SHOW` commands: `YIELD` before `WHERE`; combinable with general Cypher clauses incl. `UNION`/`RETURN` [2026.05] — `SHOW DATABASES` still requires system db (use `USE system`) +9. Inline node predicates `(:Label WHERE p=x)` — valid in `MATCH` only +10. `WHERE` cannot follow bare `UNWIND` — use `WITH x WHERE` +11. `(a)-[:R]-(b)` — undirected matches both directions, double-counts; use directed unless unknown +12. `DETACH DELETE` — plain `DELETE` throws if node has relationships + +--- + +## Style + +| Element | Convention | +|---|---| +| Node labels | PascalCase `:Person` | +| Rel types | SCREAMING_SNAKE_CASE `:KNOWS` | +| Properties/vars | camelCase `firstName` | +| Clauses | UPPERCASE `MATCH` | +| Booleans/null | lowercase `true false null` | +| Strings | single-quoted; double only if contains `'` | + +> Schema is truth. `:Person`, `:KNOWS`, `name` in examples are illustrative — substitute real names from schema. + +--- + +## Schema-First Protocol + +**Priority order:** + +1. `-schema.json` anywhere in project → read directly, state file name + `schema_retrieved_at`, skip live inspection. If significantly outdated and DB reachable, offer re-fetch. Full rules: [references/schema-guardrail.md](references/schema-guardrail.md). + - **Existence** — labels/rel-types/properties must be in schema; try synonym resolution before asking + - **Property type** — reason about intent first (e.g. string vs INTEGER may be null check); ask only if unclear + - **Relationship direction** — wrong direction → correct silently and note + - **Synonym mapping** — unambiguous → resolve silently; ambiguous → pick most likely, note; ask if unresolvable + + Scripts: `generate_schema.py` (live DB + APOC), `define_schema.py` (no DB), `import_neo4j_schema.py` (converts `neo4j-graphrag-python`, `graph-schema-introspector`, `graph-schema-json-js-utils`, `mcp-neo4j-data-modeling`). + +2. Schema in context → use it, skip inspection. + +3. Schema missing → run: +```cypher +CALL db.schema.visualization() YIELD nodes, relationships RETURN nodes, relationships; +SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state WHERE state = 'ONLINE'; +SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties; +SHOW PROCEDURES YIELD name RETURN split(name,'.')[0] AS namespace, count(*) AS procedures; +``` + +Property types per label — check APOC first: +```cypher +// If APOC available (preferred — use this): +CALL apoc.meta.schema() YIELD value RETURN value; + +// No APOC AND database ≤ 100k nodes/rels only (expensive on large graphs): +CALL db.schema.nodeTypeProperties() YIELD nodeType, propertyName, propertyTypes, mandatory; +CALL db.schema.relTypeProperties() YIELD relType, propertyName, propertyTypes, mandatory; +``` + +Validate before returning any query: label exists · rel type+direction correct · property on that label · index ONLINE. + +--- + +## Key Patterns + +### MERGE +```cypher +// MERGE on constrained key; set extras in ON CREATE/ON MATCH +CYPHER 25 +MATCH (a:Person {id: $a}) MATCH (b:Person {id: $b}) +MERGE (a)-[r:KNOWS]->(b) + ON CREATE SET r.since = date() + ON MATCH SET r.lastSeen = date() +``` +`SET n = {}` replaces all props. `SET n += {}` merges (safe partial update). Use `+=` for updates. + +### WITH scope +```cypher +CYPHER 25 +MATCH (a:Person)-[:KNOWS]->(b:Person) +WITH a, count(*) AS friends // b dropped here +WHERE friends > 5 +RETURN a.name, friends ORDER BY friends DESC +``` +Every var not listed in `WITH` is dropped. `WITH *` carries all forward. + +### Subqueries — cheat sheet +``` +EXISTS { (a)-[:R]->(b) } // boolean check +COUNT { (a)-[:R]->(b) WHERE a.x > 0 } // count +COLLECT { MATCH (a)-[:R]->(b) RETURN b.name } // collect list (full MATCH+RETURN required) +CALL (p) { MATCH (p)-[:ACTED_IN]->(m) RETURN m } // correlated subquery (explicit import) +OPTIONAL CALL (p) { ... } // nullable subquery +``` +`CALL { WITH x ... }` deprecated → `CALL (x) { ... }`. `COLLECT {}` returns exactly one column. + +### CALL IN TRANSACTIONS (bulk writes) +```cypher +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row +CALL (row) { + MERGE (p:Person {id: row.id}) SET p += row +} IN TRANSACTIONS OF 1000 ROWS ON ERROR CONTINUE REPORT STATUS AS s +``` +Input stream must be outside subquery. Auto-commit only — never wrap in `beginTransaction()`. `PERIODIC COMMIT` deprecated. + +`DISJOINT BY` [2026.06, Cypher 25] on `IN CONCURRENT TRANSACTIONS` prevents deadlocks by scheduling batches that share lock-prone resources sequentially — use when importing relationships: +```cypher +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///rels.csv' AS line +CALL (line) { + MATCH (a:Movie {id: line.movieId}), (b:Person {id: line.personId}) + MERGE (b)-[:ACTED_IN]->(a) +} IN CONCURRENT TRANSACTIONS OF 1000 ROWS DISJOINT BY (line.movieId, line.personId) +``` +`DISJOINT BY (expr,...)` declares lock keys (outer-query variables only); `DISJOINT BY AUTO` infers them via static analysis; `DISJOINT BY NONE` disables. Overrides `dbms.cypher.transactions.default_subquery_batch_strategy`. `EXPLAIN`/`PROFILE` shows keys in `DISJOINT BY (...)` on `TransactionForeach`. + +### QPE basics +```cypher +CYPHER 25 +MATCH SHORTEST 1 (a:Person {name:'Alice'})(()-[:KNOWS]->()){1,}(b:Person {name:'Bob'}) +RETURN b.name + +// ACYCLIC [2026.03] — no repeated nodes within a path (prevents cycles) +CYPHER 25 +MATCH p = ACYCLIC (start:Router {name: $from})-[:LINK]-+(end:Router {name: $to}) +RETURN [n IN nodes(p) | n.name] AS route +ORDER BY length(p) LIMIT 5 +``` +Quantifier outside group: `(pattern){N,M}`. Groups start+end with node. `REPEATABLE ELEMENTS` needs bounded `{m,n}`. `ACYCLIC` implies nodes cannot repeat within a path (stronger than default `DIFFERENT RELATIONSHIPS`). + +Match mode — add after `MATCH`: +- `DIFFERENT RELATIONSHIPS` (default) — each rel traversed once per path +- `REPEATABLE ELEMENTS` [2025.x] — nodes/rels revisitable; use for circular routes, weight-optimized paths, constrained backtracking; requires bounded `{m,n}` + +### Conditional CALL subqueries [2025.06] +```cypher +CYPHER 25 +MATCH (move:Item {id: $id}) +OPTIONAL MATCH (insertBefore:Item {id: $before}) +OPTIONAL MATCH (insertAfter:Item {id: $after}) +CALL (move, insertBefore, insertAfter) { + WHEN insertBefore IS NULL THEN { + MATCH (last:Item) WHERE NOT (last)-[:NEXT]->() AND last <> move + CREATE (last)-[:NEXT]->(move) + } + WHEN insertAfter IS NULL THEN { + CREATE (move)-[:NEXT]->(insertBefore) + } + ELSE { + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) + } +} +``` +Use WHEN…THEN…ELSE for if-else-if write logic; mutually exclusive (first match wins). Not available pre-2025.06. + +### Dynamic relationship types [2025.x] +```cypher +// Create/match/merge with dynamic rel type (must resolve to exactly one STRING) +CYPHER 25 CREATE (a:Node)-[:$($relType)]->(b:Node) +CYPHER 25 MATCH (a:Node)-[:$($relType)]->(b:Node) RETURN a.name, b.name +``` + +### Spatial / Point +```cypher +// WGS84 geographic point +SET n.coords = point({longitude: $lon, latitude: $lat}) + +// Distance in metres; requires POINT index for performance +MATCH (a:Place {name: $origin}) MATCH (b:Place) +RETURN b.name, point.distance(a.coords, b.coords) AS distM +ORDER BY distM LIMIT 10 + +// Bounding-box pre-filter (uses POINT index) then distance +MATCH (b:Place) +WHERE point.withinBBox(b.coords, + point({longitude: $west, latitude: $south}), + point({longitude: $east, latitude: $north})) +RETURN b.name, point.distance(b.coords, $origin) AS distM +``` +Create POINT index: `CREATE POINT INDEX name IF NOT EXISTS FOR (n:Place) ON (n.coords)` + +### Aggregation grouping keys +Non-aggregating expressions in `RETURN`/`WITH` are implicit grouping keys — no `GROUP BY` needed: +```cypher +// actor + director are grouping keys; count(*) is the aggregate +MATCH (a:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(d:Person) +RETURN a.name, d.name, count(*) AS collaborations +ORDER BY collaborations DESC +``` +`count(n)` counts non-null; `count(*)` counts rows including nulls. `collect(DISTINCT expr)` deduplicates. +`count()` is faster than `size(collect())` — count() reads the internal store; collect() builds a list first. + +--- + +## Common Syntax Traps (top causes of broken queries) + +| Wrong | Right | +|---|---| +| `ORDER BY n.prop AS x DESC` | `ORDER BY n.prop DESC` | +| `ORDER BY preAggVar` after agg RETURN | Use RETURN alias | +| `count(r WHERE r.x=5)` | `sum(CASE WHEN r.x=5 THEN 1 ELSE 0 END)` | +| `UNWIND list AS x WHERE x>5` | `UNWIND list AS x WITH x WHERE x>5` | +| `least(a,b)` / `greatest(a,b)` | `CASE WHEN a(b))` | `SHORTEST 1 (a)(()-[]->()){1,}(b)` | +| `id(n)` | `elementId(n)` | +| `[:REL*1..5]` | `(()-[:REL]->()){1,5}` | +| `CALL { WITH x ... }` | `CALL (x) { ... }` | +| `COLLECT { (a)-[:R]->(b) }` | `COLLECT { MATCH ... RETURN b }` | +| `SET n = {k:v}` partial update | `SET n += {k:v}` | +| `DELETE n` with relationships | `DETACH DELETE n` | +| `WHERE n.x = null` | `WHERE n.x IS NULL` | +| `toInteger(null)` throws | `toIntegerOrNull(null)` | +| `n.$key` dynamic property | `n[$key]` | +| `SET n:$label` | `SET n:$($label)` | +| `ZONED DATETIME >= date(...)` → 0 rows | Use `datetime(...)` or `.year` accessor | +| ISO string with `Z` suffix stored/compared as UTC | **`Z` ≠ UTC in Neo4j** — `Z` is parsed as an offset, not the UTC timezone; planner and range indexes treat them differently. Explicitly coerce: `datetime({datetime: datetime('2025-09-10T03:43:00Z'), timezone: 'UTC'})` ([neo4j#13519](https://github.com/neo4j/neo4j/issues/13519)) | +| `FOREACH ... RETURN` | `UNWIND ... RETURN` | + +Full trap table → [references/syntax-traps.md](references/syntax-traps.md) + +--- + +## Output Mode and Write Gate + +Default: parameterized queries. **Return named properties, not full nodes or `RETURN *`.** +```cypher +// RIGHT: agent gets named fields it can reason over +CYPHER 25 MATCH (n:Organization {name: $name}) RETURN n.name, n.founded, n.industry LIMIT 10 + +// WRONG: full node object wastes tokens, leaks all properties, agent can't extract fields cleanly +CYPHER 25 MATCH (n:Organization {name: $name}) RETURN n LIMIT 10 +``` +Exception: schema/diagnostic queries (`CALL db.schema.visualization()`, `SHOW INDEXES YIELD *`, `EXPLAIN`) where the object is the point. + +**Validation workflow:** +1. `EXPLAIN` before any write — catches syntax errors, missing indexes +2. New read: test with `LIMIT 1` first +3. Write: verify read half as `RETURN` before replacing with `SET`/`CREATE`/`DELETE` +4. `PROFILE` to measure db hits; check for `AllNodesScan`, `CartesianProduct`, `Eager` + +**Query API v2** (no driver needed — works for schema inspection, EXPLAIN, reads, writes): +```bash +curl -X POST https://.databases.neo4j.io/db//query/v2 \ + -u : -H "Content-Type: application/json" \ + -d '{"statement": "EXPLAIN MATCH (n:Person {name: $name}) RETURN n", "parameters": {"name": "Alice"}}' +# Local: http://localhost:7474/db//query/v2 +# Response: {"data": {"fields": [...], "values": [...]}} — prefix EXPLAIN to plan without executing +``` + +**Write execution gate** — only when agent executes (MCP/cypher-shell/HTTP), NOT when generating for code/scripts/user to run: +1. Run `EXPLAIN` → report estimated rows affected +2. Wait for user confirmation before executing + +--- + +## Version Gates + +Default to 2025.01-safe features when version unknown. + +| Feature | Min version | Fallback | +|---|---|---| +| `CYPHER 25`, QPEs, `CALL (x) {}` | 2025.01 | require 2025+ | +| Match modes (`DIFFERENT RELATIONSHIPS`, `REPEATABLE ELEMENTS`) | 2025.01 | require 2025+ | +| Dynamic labels `$($expr)`, `coll.sort()` | 2025.01 | APOC or app-side | +| `CONCURRENT TRANSACTIONS`, `REPORT STATUS` | 2025.01 | drop / omit | +| `SEARCH` clause (vector/fulltext) | 2026.01 | `CALL db.index.vector.queryNodes(...)` (deprecated 2026.04) | +| `ACYCLIC` path mode (no repeated nodes in path) | 2026.03 | post-filter with `size(nodes(p)) = size(apoc.coll.toSet(nodes(p)))` | +| `string.indexOf()`, `string.join()`, `string.regexReplace()` | 2026.05 | `apoc.text.*` or app-side | +| GQL aliases: `FOR`=`UNWIND`, `PROPERTY_EXISTS`=`IS NOT NULL`, `IS [NOT] LABELED`=`n:Label`; function aliases (`local_time`, `zoned_datetime`, `duration_between`, `collect_list`, etc.) | 2026.02–04 | GQL compliance only — use Cypher equivalents; full list → [references/cypher-syntax.md](references/cypher-syntax.md) | +| **GRAPH TYPE** schema DDL (`ALTER CURRENT GRAPH TYPE SET`, `EXTEND GRAPH TYPE WITH`, `DROP GRAPH TYPE ELEMENTS`, `SHOW CURRENT GRAPH TYPE`) | **2026.02 — PREVIEW** | Use individual `CREATE CONSTRAINT` / `CREATE INDEX` | + +--- + +## Performance + +EXPLAIN/PROFILE red flags: `AllNodesScan` `CartesianProduct` `NodeByLabelScan` `Eager` + +Fix Eager — three approaches (choose simplest that works): +1. **Add specific labels** to MATCH nodes to eliminate read/write ambiguity: + `MATCH (x:CallingPoint)` instead of bare `MATCH (x)` when writing `:City` nodes +2. **Collect first, then write**: `WITH collect(u) AS users UNWIND users AS u ...` +3. **CALL IN TRANSACTIONS**: isolates each batch in its own transaction + +Label inference — when planner underestimates selectivity on multi-label queries: [Neo4j 5] +```cypher +CYPHER inferSchemaParts = most_selective_label +MATCH (admin:Administrator {name: $name}), (resource:Resource {name: $res}) +MATCH p=(admin)-[:MEMBER_OF]->()-[:ALLOWED_INHERIT]->(company) +RETURN count(p) +``` + +Index anchors: every MATCH/MERGE/WHERE on a property needs an index on the lookup property or Neo4j scans all nodes. **Index only activates when the node has a label** — `MATCH (n {prop: $v})` never uses an index; `MATCH (n:Label {prop: $v})` does. MERGE without a constraint has no atomicity guarantee (two concurrent MERGEs can create duplicates). `CONTAINS`/`ENDS WITH` → TEXT index (RANGE does not support them). Force a plan with `USING INDEX n:Label(prop)` when EXPLAIN shows a scan. +Chained `OPTIONAL MATCH` for nested data → replace with `COLLECT { MATCH ... RETURN }`. +Dynamic labels (`$($label)`) → `AllNodesScan`+Filter; use static labels when possible. + +Full anti-patterns → [references/performance.md](references/performance.md) + +--- + +## Failure Recovery + +- 0 results: check param types, remove WHERE predicates one-by-one, EXPLAIN for index use +- TypeErrors: use `toIntegerOrNull()`/`toFloatOrNull()`; guard with `IS NOT NULL` +- Variable out of scope: not listed in `WITH` → use `count(*)` not `count(droppedVar)` +- Timeouts: fix AllNodesScan → add early `LIMIT` → `CALL IN TRANSACTIONS OF 1000 ROWS` +- Long-running query progress [2026.03]: `SHOW TRANSACTIONS YIELD currentQuery, status, currentQueryProgress` +- DateTime mismatch: `ZONED DATETIME >= date(...)` → 0 rows; use `datetime()` or `.year` +- `Z` suffix ≠ UTC timezone: ISO strings with `Z` are stored as a UTC-offset, not the UTC zone — range queries across `Z` and `UTC` stored values return 0 rows. Coerce on write: `datetime({datetime: datetime($isoStr), timezone: 'UTC'})` +- Duration: `.inDays`/`.inMonths` don't exist; use `.days`/`.months` +- `Cannot merge node using null property value`: MERGE key resolved to null — validate params first +- `IndexNotFoundError`: `SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE'` + +--- + +## References + +Load on demand: +- [references/indexes.md](references/indexes.md) — index types (RANGE/TEXT/FULLTEXT/POINT/COMPOSITE/LOOKUP), constraints, MERGE lock semantics, fulltext Lucene syntax, import pre-flight +- [references/cypher-syntax.md](references/cypher-syntax.md) — full syntax reference: WITH, DELETE, ORDER BY, CASE, null, lists, strings, dates, spatial/point, LOAD CSV, subqueries, QPEs, dynamic labels, SEARCH; conditional CALL (WHEN/THEN/ELSE); label pattern expressions; allReduce; NEXT clause; compact CASE WHEN; normalize(); index/constraint types table; functions annotated with version introduced +- [references/syntax-traps.md](references/syntax-traps.md) — 40+ syntax trap table +- [references/performance.md](references/performance.md) — anti-patterns, text vs fulltext indexes, Eager (3 fix strategies), label inference, batching best practices, parallel runtime +- [references/advanced-patterns.md](references/advanced-patterns.md) — REPEATABLE ELEMENTS patterns, allReduce stateful traversal, multi-stop QPE, route planning simulation, DAG critical path, temporal fraud detection component graph, cycle detection, OPTIONAL CALL +- [references/apoc.md](references/apoc.md) — APOC Core: refactoring, virtual graph, merge helpers, path expanders, triggers, collections, conditional execution +- [references/graph-type.md](references/graph-type.md) — **PREVIEW (2026.02+)** GRAPH TYPE DDL: `ALTER CURRENT GRAPH TYPE SET`, `EXTEND GRAPH TYPE WITH`, `DROP GRAPH TYPE ELEMENTS`, property types, constraints, label implications, relationship type enforcement + +## WebFetch + +| Need | URL | +|---|---| +| Clause semantics | `https://neo4j.com/docs/cypher-manual/25/clauses/{clause}/` | +| Function signatures | `https://neo4j.com/docs/cypher-manual/25/functions/{type}/` | +| QPE / paths | `https://neo4j.com/docs/cypher-manual/25/patterns/` | +| Spatial/point functions | `https://neo4j.com/docs/cypher-manual/25/functions/spatial/` | +| Index/constraint reference | `https://neo4j.com/docs/cypher-manual/25/indexes/` | +| Full cheat sheet | `https://neo4j.com/docs/cypher-cheat-sheet/25/all/` | + +--- + +## Checklist +- [ ] Schema inspected or confirmed in context +- [ ] `CYPHER 25` prefix on every top-level query +- [ ] `$parameters` used (not literals) +- [ ] `LIMIT` on exploratory reads (default 25) +- [ ] `EXPLAIN` run; red flags resolved +- [ ] Write half verified as `RETURN` before executing +- [ ] Write execution gate applied if agent is executing (not generating) +- [ ] `MERGE` on constrained key only +- [ ] No label-free `MATCH (n)` +- [ ] Schema ops not inside explicit transaction diff --git a/.agents/skills/neo4j-cypher-skill/references/advanced-patterns.md b/.agents/skills/neo4j-cypher-skill/references/advanced-patterns.md new file mode 100644 index 0000000000..5c7d6f68f3 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/advanced-patterns.md @@ -0,0 +1,243 @@ +# Advanced Graph Patterns + +Load when solving path-finding, fraud detection, DAG traversal, temporal graphs, or stateful QPE problems. + +Version markers: `[Neo4j 5]` = Neo4j 5.x, `[2025.x]` = Neo4j 2025.x / Cypher 25, `[2025.06]` = 2025.06+. + +--- + +## REPEATABLE ELEMENTS — When to Use [2025.x] + +Default: `DIFFERENT RELATIONSHIPS` — each relationship traversed at most once per path. +Use `REPEATABLE ELEMENTS` when: +- Nodes have limited connectivity (single in/out relationship) and weight-optimized paths are needed +- Problem requires backtracking through already-visited nodes (circular routes, constrained path search) +- Path must revisit waypoints (multi-stop routes, recurring visits) + +```cypher +// Find circular routes from CPH using same connections multiple times +CYPHER 25 +MATCH (cph:Airport {iata: 'CPH'}) +MATCH REPEATABLE ELEMENTS p=(cph)(()-[c:CONNECTION]-() WHERE c.km < 548){2,6}(cph) +WITH p, reduce(d=0, c IN relationships(p) | d + c.km) AS distance +WHERE distance >= 805 +ORDER BY distance LIMIT 1 +RETURN p, distance +``` + +`REPEATABLE ELEMENTS` requires bounded quantifier `{m,n}` — do not use `{1,}` (unbounded). + +--- + +## Multi-Stop Shortest Path [2025.x] + +Multiple waypoints in one query via chained QPE groups: + +```cypher +CYPHER 25 +MATCH REPEATABLE ELEMENTS p = + ALL SHORTEST (:Airport {iata:'CPH'})--{,10} + (:Airport {iata:'IFJ'})--{,10} + (:Airport {iata:'DFW'}) +WITH p, reduce(d=0, c IN relationships(p) | d + c.km) AS distance +ORDER BY distance LIMIT 1 +RETURN p, distance +``` + +--- + +## Stateful Route Planning with allReduce [2025.x] + +`allReduce` for simulation-style traversal where path validity depends on accumulated state (energy, time, cost): + +```cypher +CYPHER 25 runtime=parallel +MATCH (src:Geo {name: $source}), (dst:Geo {name: $target}) +MATCH REPEATABLE ELEMENTS p=(src)(()-[r:ROAD|CHARGE]-(x:Geo)){1,12}(dst) +WHERE allReduce( + curr = {soc: $initial_soc_pct, mins: 0.0}, + r IN relationships(p) | + CASE + WHEN r:ROAD THEN {soc: curr.soc - r.drain_pct, mins: curr.mins + r.drive_mins} + WHEN r:CHARGE THEN {soc: curr.soc + r.charge_pct, mins: curr.mins + r.charge_mins} + END, + $min_soc <= curr.soc <= $max_soc AND curr.mins <= $max_mins +) +// Spatial pre-filter: skip detours > 1.3x direct distance +AND ALL(x IN nodes(p) WHERE + point.distance(x.geo, dst.geo) < 1.3 * point.distance(src.geo, dst.geo)) +RETURN p, reduce(d=0, r IN relationships(p) | d + r.drive_mins) AS total_mins +ORDER BY total_mins ASC LIMIT 1 +``` + +`allReduce(accumulator = initial, item IN list | updateExpr, predicate)` — returns `true` only if predicate holds at every step. Prunes invalid paths inline during expansion. + +--- + +## DAG Traversal and Critical Path [Neo4j 5] + +Model: ActivityStart/ActivityEnd nodes connected by weighted `:ACTIVITY` edges; zero-weight `:DEPENDS_ON` edges for sequencing. + +```cypher +// Longest path (critical path) — small graphs only +// For large graphs use gds.dag.longestPath instead +CYPHER 25 +MATCH p=(a:ActivityStart)-[:ACTIVITY|DEPENDS_ON]*->(b:ActivityEnd) +WITH b.name AS task, + reduce(t=0, r IN [x IN relationships(p) WHERE type(x)='ACTIVITY' | x] | + t + r.expectedTime) AS totalTime +RETURN task, max(totalTime) AS criticalPathTime +ORDER BY criticalPathTime DESC + +// GDS alternative for large DAGs (much faster): +// CALL gds.dag.longestPath.stream('dag_graph') YIELD nodeId, distance +``` + +Limitation: Cypher QPE for longest path fails on large graphs; use `gds.dag.longestPath` for production. + +--- + +## Fraud Detection — Temporal Component Graph [2025.x] + +Pattern: User-Event-Thing model where fraud rings = connected components sharing resources (IPs, devices, emails). + +**Problem**: Standard WCC includes future events → "future leakage" in ML features. +**Solution**: Chronological `:SAME_CC_AS` forest — each event links only to components existing at its timestamp. + +```cypher +// Step 1: Build temporal connected components (process events in timestamp order) +CYPHER 25 +MATCH (e:Event&!ConnectedComponent) +WITH e ORDER BY e.timestamp +CALL (e) { + MATCH (e)(()-[:WITH]->(entity)<-[:WITH]-(:ConnectedComponent)){0,1}()<-[:COMMITS]-(u) + WITH DISTINCT e, u + MATCH (u)-[:SAME_CC_AS]->*(cc WHERE NOT EXISTS {(cc)-[:SAME_CC_AS]->()}) + MERGE (cc)-[:SAME_CC_AS]->(e) + SET e:ConnectedComponent +} IN TRANSACTIONS OF 100 ROWS + +// Step 2: Point-in-time component snapshot (features as of $asOfDate) +CYPHER 25 +MATCH (cc:Event) +WHERE cc.timestamp <= $asOfDate + AND NOT EXISTS {(cc)-[:SAME_CC_AS]->(x:Event WHERE x.timestamp <= $asOfDate)} +RETURN cc + +// Step 3: Retrieve component membership for an event +CYPHER 25 +MATCH p=(u:User)(()-[:SAME_CC_AS]->(ev))*(e:Event {event_id: $event_id}) +UNWIND ev + [e] AS event +RETURN p, [(event)-[r:WITH]->(x) | [r, x]] AS with_things +``` + +**Scaling with GDS WCC** — process independent components in parallel: +```cypher +CYPHER 25 +CALL gds.wcc.stream('wcc_graph') YIELD nodeId, componentId +WITH gds.util.asNode(nodeId) AS event, componentId +WITH componentId, collect(event) AS events +ORDER BY rand() +CALL (events) { + UNWIND events AS e + WHERE NOT e:ConnectedComponent + ORDER BY e.timestamp ASC + CALL (e) { + MATCH (e)(()-[:WITH]->(entity)<-[:WITH]-(:ConnectedComponent)){0,1}()<-[:COMMITS]-(p) + WITH DISTINCT e, p + MATCH (p)-[:SAME_CC_AS]->*(cc WHERE NOT EXISTS {(cc)-[:SAME_CC_AS]->()}) + MERGE (cc)-[:SAME_CC_AS]->(e) + SET e:ConnectedComponent + } +} IN CONCURRENT TRANSACTIONS OF 100 ROWS +``` + +**Avoid O(n²) clique projection** — use linear path through shared entity instead: +```cypher +CYPHER 25 +MATCH (thing:Thing|User) +CALL (thing) { + MATCH (e:Event)-[:WITH|COMMITS]-(thing) + WITH DISTINCT e + WITH collect(e) AS events + WITH CASE size(events) WHEN 1 THEN [events[0], null] ELSE events END AS events + UNWIND range(0, size(events)-2) AS ix + RETURN events[ix] AS source, events[ix+1] AS target +} +RETURN gds.graph.project('wcc_graph', source, target, {}) +``` + +--- + +## Cycle Detection with QPE [Neo4j 5] + +Detect non-repeating cycles without artificial length limits: + +```cypher +// All cycles through a node (bounded for safety) +CYPHER 25 +MATCH (start:Account {id: $id}) +MATCH DIFFERENT RELATIONSHIPS p=(start)(()-[:TRANSFERS_TO]->()){2,10}(start) +RETURN p, length(p) AS cycleLength +ORDER BY cycleLength LIMIT 20 + +// Count paths through complex small-graph traversal +CYPHER 25 runtime=parallel +MATCH REPEATABLE ELEMENTS path=(:Start)((xs:!End)--(:!Start)){0,100}(e:End) +WHERE allReduce( + visited = [], + x IN xs | CASE WHEN x:Big THEN visited ELSE visited + [x] END, + size(visited) <= size(apoc.coll.toSet(visited)) + 1 +) +RETURN count(path) AS validPaths +``` + +--- + +## Path Selector Reference [2025.x] + +| Selector | Returns | Use case | +|---|---|---| +| `SHORTEST 1` | One shortest path | Existence + distance | +| `ALL SHORTEST` | All equal-minimum-length paths | Parallel routing | +| `ANY` | Any path (no length guarantee) | Fast existence check | +| `SHORTEST k` | k shortest paths | Top-k routing | +| `SHORTEST k GROUPS` | All paths grouped by length up to k distinct lengths | Tier-based routing | + +```cypher +// k-shortest paths with cost +CYPHER 25 +MATCH SHORTEST 3 (a:City {name: $from})(()-[r:ROAD]->()){1,}(b:City {name: $to}) +WITH *, reduce(c=0, r IN relationships(*) | c + r.cost) AS totalCost +ORDER BY totalCost +RETURN totalCost, [n IN nodes(*) | n.name] AS route +``` + +--- + +## Type Predicate for Schema Discovery [Neo4j 5] + +Identifies properties by runtime type — useful in GraphRAG pipelines to auto-detect text fields: + +```cypher +// Find all STRING properties on nodes in a label +CYPHER 25 +MATCH (n:Article) +WITH keys(n) AS props, n LIMIT 1 +UNWIND props AS p +WHERE n[p] IS :: STRING NOT NULL +RETURN p AS textProperty +``` + +--- + +## OPTIONAL CALL [Neo4j 5] + +Left-outer join for procedures — row kept even if procedure returns no results: + +```cypher +CYPHER 25 +MATCH (m:Movie) +OPTIONAL CALL apoc.algo.dijkstra(m, $target, 'ROAD', 'distance') YIELD path, weight +RETURN m.title, weight // weight is null when no path found +``` diff --git a/.agents/skills/neo4j-cypher-skill/references/apoc.md b/.agents/skills/neo4j-cypher-skill/references/apoc.md new file mode 100644 index 0000000000..eb735d72df --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/apoc.md @@ -0,0 +1,381 @@ +# APOC Core Reference + +Load when using APOC procedures for graph refactoring, virtual graphs, merge helpers, path expansion, triggers, or utility operations. + +Verify APOC available: `RETURN apoc.version()` + +APOC Core ships bundled with Neo4j. APOC Extended is a separate Labs plugin — procedures below are Core only. + +--- + +## Graph Metadata + +```cypher +// Schema snapshot: labels, rel types, properties, counts +CALL apoc.meta.schema() YIELD value RETURN value + +// Fast label/rel-type/property counts (sampled) +CALL apoc.meta.stats() YIELD labels, relTypesCount, properties RETURN * + +// Per-label property details (name, type, nullable, indexed) +CALL apoc.meta.nodeTypeProperties() +YIELD nodeType, propertyName, propertyTypes, mandatory RETURN * + +// Per-rel-type property details +CALL apoc.meta.relTypeProperties() +YIELD relType, propertyName, propertyTypes, mandatory RETURN * +``` + +`apoc.meta.schema()` samples the graph; `apoc.meta.stats()` is near-instant from counters. + +--- + +## Graph Refactoring + +### Rename labels / types / properties + +```cypher +// Rename label on all nodes (optional: pass list to limit scope) +CALL apoc.refactor.rename.label('OldLabel', 'NewLabel', []) + +// Rename relationship type +CALL apoc.refactor.rename.type('OLD_TYPE', 'NEW_TYPE', []) + +// Rename node property across all (or matched) nodes +CALL apoc.refactor.rename.nodeProperty('oldProp', 'newProp', []) + +// Rename relationship property +CALL apoc.refactor.rename.relationshipProperty('oldProp', 'newProp', []) +``` + +Config optional: `{batchSize: 10000, parallel: true}`. Third argument `[]` means all; pass a list of nodes/rels to scope. + +### Merge nodes + +```cypher +// Merge person duplicates — first node is target +MATCH (a:Person {email: $email}) +WITH collect(a) AS dupes +CALL apoc.refactor.mergeNodes(dupes, { + properties: 'combine', // 'overwrite' | 'discard' | 'combine' + mergeRels: true +}) YIELD node RETURN node +``` + +### Clone nodes + +```cypher +// Clone without relationships +MATCH (n:Template {id: $id}) +CALL apoc.refactor.cloneNodes([n], false, []) YIELD output RETURN output + +// Clone with relationships +CALL apoc.refactor.cloneNodes([n], true, ['internalId']) YIELD input, output, error +``` + +### Extract node from relationship + +Splits a relationship into a node-rel-node triple: + +```cypher +// apoc.refactor.extractNode(rels, [labels], outRelType, inRelType) +MATCH ()-[r:TRANSACTION]->() WHERE r.amount > 10000 +WITH collect(r) AS bigTxns +CALL apoc.refactor.extractNode(bigTxns, ['HighValueTx'], 'HAS_TX', 'FROM_ACCT') +YIELD input, output RETURN output +``` + +--- + +## Merge Helpers (dynamic MERGE) + +Use when label or rel-type is a parameter — Cypher `MERGE` requires literal labels at compile time. + +```cypher +// apoc.merge.node(labels, identProps [, onCreateProps, onMatchProps]) +CALL apoc.merge.node(['Person'], {email: $email}, + {createdAt: datetime()}, + {lastSeen: datetime()} +) YIELD node RETURN node + +// apoc.merge.relationship(startNode, relType, identProps, onCreateProps, endNode [, onMatchProps]) +MATCH (a:Company {id: $from}), (b:Company {id: $to}) +CALL apoc.merge.relationship(a, $relType, {}, {since: date()}, b, {}) +YIELD rel RETURN rel +``` + +--- + +## Virtual Graph (in-memory, no write) + +Virtual nodes/relationships exist only in query result — for projecting computed subgraphs to visualization tools or passing to other APOC procedures. + +```cypher +// Virtual node — does NOT persist to DB +WITH apoc.create.vNode(['Person'], {name: 'Alice', score: 0.9}) AS vn +RETURN vn + +// Virtual relationship between two real nodes +MATCH (a:Person {id: $a}), (b:Person {id: $b}) +WITH a, b, apoc.create.vRelationship(a, 'SIMILAR_TO', {score: 0.85}, b) AS vr +RETURN a, vr, b + +// Virtual subgraph from Cypher statement +CALL apoc.graph.fromCypher( + 'MATCH (a:Person)-[r:KNOWS]->(b:Person) WHERE a.age < 30 RETURN a, r, b', + {}, 'youngNetwork', {} +) YIELD graph RETURN graph +``` + +--- + +## Path Expanders + +Variable-depth traversal with label/rel-type filters. Use when depth is unknown at write time or needs runtime configuration. + +### expandConfig — flexible traversal + +```cypher +// apoc.path.expandConfig(startNode, config) :: (path) +MATCH (start:Person {id: $id}) +CALL apoc.path.expandConfig(start, { + minLevel: 1, + maxLevel: 3, + relationshipFilter: 'KNOWS>|WORKS_AT', // direction: > out, < in, omit = both + labelFilter: '+Person|+Company|-Blocked', // + whitelist, - blacklist + uniqueness: 'NODE_GLOBAL', // NODE_GLOBAL|RELATIONSHIP_GLOBAL|NODE_PATH + bfs: true, + limit: 100 +}) YIELD path RETURN path +``` + +### subgraphAll — all nodes + rels in subgraph + +```cypher +// Returns LIST + LIST +MATCH (root:Company {id: $id}) +CALL apoc.path.subgraphAll(root, { + maxLevel: 2, + relationshipFilter: 'SUBSIDIARY_OF|OWNS' +}) YIELD nodes, relationships RETURN nodes, relationships +``` + +### spanningTree — spanning tree paths + +```cypher +MATCH (root:Person {id: $id}) +CALL apoc.path.spanningTree(root, { + maxLevel: 3, + relationshipFilter: 'FOLLOWS>' +}) YIELD path RETURN path +``` + +`labelFilter` syntax: `+WhitelistLabel`, `-BlacklistLabel`, `>TerminatorLabel`, `/EndNodeLabel`. + +--- + +## Triggers + +Fire Cypher on write events. Require `apoc.trigger.enabled=true` in `apoc.conf`. + +**For Neo4j 2025.x / Cypher 25:** use `apoc.trigger.install` (system db) + `apoc.trigger.list`. +`apoc.trigger.add` / `apoc.trigger.remove` / `apoc.trigger.pause` were removed in Cypher 25. + +```cypher +// Install — run from system database +USE system +CALL apoc.trigger.install( + 'neo4j', // target database + 'stamp-created', // trigger name + 'UNWIND $createdNodes AS n SET n.createdAt = datetime()', + {phase: 'before'} // before | after | rollback | afterAsync +) YIELD name, installed RETURN name, installed + +// List triggers for current database +CALL apoc.trigger.list() +YIELD name, query, selector, installed, paused RETURN * + +// Pause / drop (system db) +USE system +CALL apoc.trigger.pause('neo4j', 'stamp-created') +CALL apoc.trigger.drop('neo4j', 'stamp-created') +``` + +Available bindings in trigger statement: `$createdNodes`, `$deletedNodes`, `$assignedLabels`, `$removedLabels`, `$assignedNodeProperties`, `$removedNodeProperties`, `$createdRelationships`, `$deletedRelationships`. + +--- + +## Conditional Execution + +```cypher +// apoc.do.when — read/write branching +CALL apoc.do.when( + size($ids) > 0, + 'MATCH (n:Person) WHERE n.id IN $ids SET n.active = true RETURN count(n)', + 'RETURN 0 AS count', + {ids: $ids} +) YIELD value RETURN value + +// apoc.do.case — multi-branch +CALL apoc.do.case( + [$score > 0.9, 'RETURN "high" AS tier', + $score > 0.5, 'RETURN "mid" AS tier'], + 'RETURN "low" AS tier', + {score: $score} +) YIELD value RETURN value.tier +``` + +`apoc.do.when` / `apoc.do.case` execute write Cypher; `apoc.when` / `apoc.case` are read-only variants. + +Both deprecated in Cypher 25 — use native `CASE` + conditional `CALL { ... }` or `OPTIONAL CALL`. + +--- + +## Collections + +```cypher +// Flatten nested list +RETURN apoc.coll.flatten([[1,2],[3,[4,5]]], true) // [1,2,3,4,5] + +// Distinct union of two lists +RETURN apoc.coll.union([1,2,3], [2,3,4]) // [1,2,3,4] + +// Deduplicate list +RETURN apoc.coll.toSet([1,2,2,3]) // [1,2,3] +``` + +`flatten` and `toSet` deprecated in Cypher 25 — use `apoc.coll.flatten` only for deeply nested lists where the native `[x IN list | ...]` flattening is insufficient. + +--- + +## Maps + +```cypher +// Merge two maps (right overwrites left on key collision) +RETURN apoc.map.merge({a:1, b:2}, {b:3, c:4}) // {a:1, b:3, c:4} + +// Build map from list of [key, value] pairs +RETURN apoc.map.fromPairs([['k1',1],['k2',2]]) // {k1:1, k2:2} + +// Extract sub-map by keys +RETURN apoc.map.submap({a:1,b:2,c:3}, ['a','c']) // {a:1, c:3} +``` + +--- + +## JSON Conversion + +```cypher +// Serialize any Cypher value to JSON string +MATCH (n:Event {id: $id}) +RETURN apoc.convert.toJson(n{.*}) + +// Parse JSON string → Cypher list +WITH '[{"name":"Alice"},{"name":"Bob"}]' AS raw +RETURN apoc.convert.fromJsonList(raw, '$[*].name', []) // ['Alice','Bob'] + +// Parse JSON string → Cypher map +WITH '{"score":0.9,"tier":"A"}' AS raw +RETURN apoc.convert.fromJsonMap(raw, null, []) +``` + +--- + +## Date / Time Utilities + +`apoc.date.*` deprecated in Cypher 25 — use native `datetime()`, `date()`, `duration`. Use APOC date only when parsing non-ISO legacy format strings or converting epoch integers. + +```cypher +// Parse legacy date string → epoch ms +RETURN apoc.date.parse('2024-03-15 09:00:00', 'ms', 'yyyy-MM-dd HH:mm:ss') + +// Format epoch ms → string +RETURN apoc.date.format(1710489600000, 'ms', 'yyyy-MM-dd', 'UTC') + +// Convert between units (ms → s) +RETURN apoc.date.convert(1710489600000, 'ms', 's') +``` + +--- + +## String Utilities + +```cypher +// Split by regex +RETURN apoc.text.split('a,b,,c', ',', 0) // ['a','b','','c'] + +// Join list of strings +RETURN apoc.text.join(['foo','bar','baz'], '-') // 'foo-bar-baz' + +// URL-safe slug +RETURN apoc.text.slug('Hello World! 2025', '-') // 'hello-world-2025' + +// Regex capture groups +RETURN apoc.text.regexGroups('2025-04-01', '(\\d{4})-(\\d{2})-(\\d{2})') +// [['2025-04-01','2025','04','01']] +``` + +--- + +## Node Lookup by ID + +```cypher +// Fetch nodes by internal id list +CALL apoc.nodes.get([123, 456, 789]) YIELD node RETURN node +``` + +Prefer `elementId(n)` over integer IDs — stable across restores. + +--- + +## Export + +Requires `apoc.export.file.enabled=true` in `apoc.conf`. Pass `{stream:true}` to return data inline instead of file output. + +```cypher +// Export query results to CSV (inline) +CALL apoc.export.csv.query( + 'MATCH (p:Person) RETURN p.name AS name, p.age AS age', + null, + {stream: true} +) YIELD data RETURN data + +// Export to JSON file +CALL apoc.export.json.query( + 'MATCH (n:Event)-[r:ATTENDED_BY]->(p:Person) RETURN n, r, p', + '/var/lib/neo4j/import/events.json', + {} +) YIELD file, nodes, rels, properties RETURN * + +// Export as Cypher CREATE/MERGE statements +CALL apoc.export.cypher.query( + 'MATCH (n:Config) RETURN n', + '/var/lib/neo4j/import/config.cypher', + {format: 'cypher-shell'} // cypher-shell | plain | neo4j-shell +) YIELD file RETURN file +``` + +--- + +## Deprecation Summary (Cypher 25) + +| Deprecated | Replacement | +|---|---| +| `apoc.trigger.add` / `.remove` / `.pause` | `apoc.trigger.install` / `.drop` / `.pause` (system db) | +| `apoc.do.when` / `apoc.do.case` | Native `CASE` + conditional `CALL {}` | +| `apoc.coll.flatten` (simple) | `[x IN nested | x]` list comprehension | +| `apoc.coll.toSet` | `apoc.coll.toSet` still works; or `DISTINCT` in collect | +| `apoc.date.parse` / `.format` / `.convert` | `datetime()`, `date()`, `duration()` native functions | +| `apoc.periodic.iterate` | `CALL { ... } IN TRANSACTIONS OF N ROWS` | + +--- + +## WebFetch + +| Need | URL | +|---|---| +| Full procedure list | `https://neo4j.com/docs/apoc/current/overview/` | +| Path expander config | `https://neo4j.com/docs/apoc/current/graph-querying/path-expander/` | +| Trigger reference | `https://neo4j.com/docs/apoc/current/background-operations/triggers/` | +| Refactoring ops | `https://neo4j.com/docs/apoc/current/graph-refactoring/` | +| Export config | `https://neo4j.com/docs/apoc/current/export-import/` | diff --git a/.agents/skills/neo4j-cypher-skill/references/cypher-syntax.md b/.agents/skills/neo4j-cypher-skill/references/cypher-syntax.md new file mode 100644 index 0000000000..c7a79fee80 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/cypher-syntax.md @@ -0,0 +1,808 @@ +# Cypher Syntax Reference + +Full syntax reference for clauses, patterns, and functions. +Version markers: `[2025.01]` = new/changed in Cypher 25 / Neo4j 2025.x — older models default to the pre-2025 form. +`[2026.01]` = requires Neo4j 2026.x. Unmarked = stable pre-2025, well-known priors. + +--- + +## Index and Constraint Types + +### Index decision table + +| Index type | Best for | `CONTAINS`/`ENDS WITH` | Spatial | Fulltext | +|---|---|---|---|---| +| `RANGE` | `=`, `>`, `<`, `STARTS WITH`, `IS NOT NULL` | Slow (use TEXT instead) | ❌ | ❌ | +| `TEXT` | `CONTAINS`, `ENDS WITH`, `=` on strings, list `IN` with strings | ✅ | ❌ | ❌ | +| `POINT` | `point.distance()`, `point.withinBBox()` | ❌ | ✅ | ❌ | +| `FULLTEXT` | Lucene tokenized search; multiple labels/props | ❌ | ❌ | ✅ | +| `COMPOSITE` | Queries always testing 2+ properties together | — | ❌ | ❌ | + +Create syntax: +```cypher +CREATE RANGE INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop) +CREATE TEXT INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop) +CREATE POINT INDEX name IF NOT EXISTS FOR (n:Label) ON (n.prop) +CREATE COMPOSITE INDEX name IF NOT EXISTS FOR (n:Label) ON (n.p1, n.p2) +CREATE FULLTEXT INDEX name IF NOT EXISTS FOR (n:Label|OtherLabel) ON EACH [n.p1, n.p2] +// Relationship index: +CREATE RANGE INDEX name IF NOT EXISTS FOR ()-[r:TYPE]-() ON (r.prop) +``` + +### Constraint types + +```cypher +// Uniqueness (+ implicitly creates RANGE index) +CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS UNIQUE + +// Existence (node property must not be null) +CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS NOT NULL + +// Relationship existence +CREATE CONSTRAINT name IF NOT EXISTS FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL + +// Node key = unique + existence (Enterprise only) +CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS NODE KEY +// Multi-property node key: +CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE (n.p1, n.p2) IS NODE KEY + +// Relationship key (Enterprise only) +CREATE CONSTRAINT name IF NOT EXISTS FOR ()-[r:TYPE]-() REQUIRE r.prop IS RELATIONSHIP KEY +``` + +Rules: +- Add uniqueness constraint on MERGE key before loading data +- `IF NOT EXISTS` prevents error on re-run +- `SHOW CONSTRAINTS YIELD name, type` to inspect + +--- + +## MERGE Safety + +```cypher +// DO: MERGE on constrained key only; set other properties in ON CREATE / ON MATCH +CYPHER 25 +MATCH (a:Person {id: $a}) MATCH (b:Person {id: $b}) +MERGE (a)-[r:KNOWS]->(b) + ON CREATE SET r.since = date() + ON MATCH SET r.lastSeen = date() + +// DON'T: MERGE on multiple non-constrained properties -- can create duplicates +// DON'T: MERGE a full path with unbound endpoints -- creates ghost nodes +// DON'T: MERGE key properties that are not in a constraint -- slow and creates duplicates +``` + +--- + +## Property Updates + +`SET n = {}` **replaces all properties** (destructive). `SET n += {}` **merges** (additive — unmentioned properties are preserved). + +```cypher +// SET = replaces -- wipes all other properties not in the map +CYPHER 25 +MATCH (n:Person {id: $id}) +SET n = {name: $name, age: $age} // every other property is removed + +// SET += merges -- safe partial update +CYPHER 25 +MATCH (n:Person {id: $id}) +SET n += {name: $name} // other properties preserved + +// Bulk import with parameter map -- set all map keys onto node +CYPHER 25 +UNWIND $rows AS row +MERGE (n:Person {id: row.id}) +SET n += row +``` + +--- + +## DELETE and REMOVE + +```cypher +// DETACH DELETE -- removes node AND all its relationships +CYPHER 25 +MATCH (n:TempNode {id: $id}) +DETACH DELETE n + +// DELETE relationship only +CYPHER 25 +MATCH (a:Person {id: $a})-[r:KNOWS]->(b:Person {id: $b}) +DELETE r + +// Plain DELETE on a node with relationships -> runtime error; always DETACH DELETE nodes + +// REMOVE a property (sets it absent -- not null, absent) +CYPHER 25 +MATCH (n:Person {id: $id}) +REMOVE n.nickname + +// REMOVE a label +CYPHER 25 +MATCH (n:Person {id: $id}) +REMOVE n:VIPMember + +// Remove ALL properties -- SET to empty map (REMOVE cannot do this) +CYPHER 25 +MATCH (n:Person {id: $id}) +SET n = {} +``` + +--- + +## WITH Scope and Aggregation + +`WITH` defines a new scope — every variable not listed is dropped. Use `WITH *` to carry all forward. + +```cypher +// Variable 'b' dropped after WITH +CYPHER 25 +MATCH (a:Person)-[:KNOWS]->(b:Person) +WITH a, count(*) AS friends // 'b' is out of scope after this line +WHERE friends > 5 +RETURN a.name, friends +ORDER BY friends DESC +``` + +`WITH` resets aggregation scope — filter on aggregates before further traversal: + +```cypher +CYPHER 25 +MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +WITH p, count(m) AS movieCount +WHERE movieCount > 3 +MATCH (p)-[:KNOWS]->(f:Person) // second MATCH uses filtered 'p' +RETURN p.name, f.name +``` + +**`count(*)` vs `count(expr)`**: `count(*)` counts all rows including nulls; `count(n)` counts only non-null values. Use `count(DISTINCT n.prop)` to deduplicate. + +**Aggregation grouping keys**: every non-aggregating expression in `RETURN`/`WITH` is implicitly a grouping key. + +--- + +## ORDER BY + +- No `AS alias` in ORDER BY items — `ORDER BY n.prop DESC` not `ORDER BY n.prop AS p DESC` +- No `NULLS LAST` / `NULLS FIRST` — SQL syntax; nulls sort last ascending / first descending by default +- After aggregation, sort by the RETURN alias, not the pre-aggregation variable + +```cypher +// DO: +CYPHER 25 +MATCH (p:Person)-[:ACTED_IN]->(m:Movie) +RETURN p.name, count(m) AS movies +ORDER BY movies DESC, p.name ASC +LIMIT 10 +``` + +--- + +## Conditional Expressions + +```cypher +// Generic CASE (if-elseif-else) +CYPHER 25 +MATCH (n:Movie) +RETURN n.title, + CASE + WHEN n.rating >= 8 THEN 'great' + WHEN n.rating >= 6 THEN 'good' + ELSE 'skip' + END AS verdict + +// Simple CASE (switch on one expression) +RETURN n.status, + CASE n.status + WHEN 'A' THEN 'Active' + WHEN 'I' THEN 'Inactive' + ELSE 'Unknown' + END AS label +``` + +No `least()` / `greatest()` — use `CASE WHEN a < b THEN a ELSE b END`. + +Conditional counting — `count(x WHERE ...)` is SQL, not Cypher: +```cypher +// DO: +sum(CASE WHEN r.rating = 5 THEN 1 ELSE 0 END) AS fiveStarCount +COUNT { MATCH (r:Review) WHERE r.rating = 5 } AS fiveStarCount +``` + +--- + +## Null Handling + +```cypher +WHERE n.email IS NOT NULL // correct +WHERE n.email = null // always null, never matches + +// coalesce() -- returns first non-null argument +RETURN coalesce(n.nickname, n.name) AS displayName +``` + +`collect()` and aggregation functions ignore null values. `null = null` is `null` (not `true`). `WHERE` treats `null` as `false`. + +--- + +## Type Coercion + +Prefer **OrNull variants** — return `null` on unconvertible input instead of throwing [2025.01; pre-2025 base forms throw]: + +```cypher +toIntegerOrNull(n.score) +toFloatOrNull(n.score) +toBooleanOrNull(n.flag) +toStringOrNull(n.value) +``` + +Type predicates for mixed-type properties: [2025.01] +```cypher +MATCH (n:Event) +WHERE n.value IS :: INTEGER NOT NULL // true only for non-null INTEGER values +RETURN n.name, n.value +``` + +**DateTime vs date() mismatch**: `datetime_prop >= date('2025-01-01')` returns 0 rows — use `.year` accessor or `datetime()` literals for `ZONED DATETIME` properties. + +**GQL compliance aliases [2026.02–04]** — valid syntax, but use the Cypher form in new code: +| GQL alias | Cypher equivalent | +|---|---| +| `FOR x IN list` | `UNWIND list AS x` | +| `PROPERTY_EXISTS(n, 'prop')` | `n.prop IS NOT NULL` | +| `n IS [NOT] LABELED Label` | `n:Label` / `NOT n:Label` | +| `FILTER` | `WHERE` | +| `LET x = expr` | `WITH expr AS x` | +| GQL function aliases `[2026.02]`: `ceiling`, `ln`, `local_time`, `local_datetime`, `zoned_time`, `zoned_datetime`, `duration_between`, `path_length`, `collect_list`, `percentile_cont`, `percentile_disc`, `stdev_samp`, `stdev_pop` | `ceil`, `log`, `localtime`, `localdatetime`, `time`, `datetime`, `duration.between`, `length`, `collect`, `percentileCont`, `percentileDisc`, `stDev`, `stDevP` | + +--- + +## List Expressions + +```cypher +[x IN list WHERE x > 0] // filter only +[x IN list | x * 2] // transform only +[x IN list WHERE x > 0 | x * 2] // filter + transform + +ANY(x IN list WHERE x > 0) +ALL(x IN list WHERE x > 0) +NONE(x IN list WHERE x > 0) +SINGLE(x IN list WHERE x > 0) + +size(list) +head(list) / tail(list) / last(list) +list[0..3] // slice +list + [newElement] +coll.sort(list) // [2025.01] native — replaces apoc.coll.sort() +``` + +`2 IN [1, null, 3]` returns `null` — guard with `IS NOT NULL` before membership tests. + +**Pattern comprehension:** +```cypher +MATCH (n:Person {id: $id}) +RETURN [(n)-[:KNOWS]->(f:Person) | f.name] AS friends, + [(n)-[:ACTED_IN]->(m:Movie) WHERE m.year > 2020 | m.title] AS recentFilms +``` + +Use pattern comprehensions for simple one-hop inline collections; for multi-step traversals use `COLLECT { MATCH ... RETURN ... }`. + +--- + +## String Functions + +```cypher +toLower(s) / toUpper(s) // case conversion (lower/upper are GQL aliases) [2025.01: lower()/upper() added as aliases] +trim(s) / ltrim(s) / rtrim(s) // strip whitespace; btrim(s, 'xy') strips custom chars [2025.01: btrim] +split(s, delimiter) // returns LIST +substring(s, start, length) // 0-indexed; length optional +left(s, n) / right(s, n) // first/last n characters +replace(s, search, replacement) // replace all occurrences +size(s) // character count (same as char_length) +reverse(s) // reverse string +toString(x) / toStringOrNull(x) // convert any type to STRING +string.indexOf(input, value) // index of first match, -1 if absent [2026.05, Cypher 25] +string.join(list, delimiter) // join LIST with delimiter [2026.05, Cypher 25] +string.regexReplace(original, regex, repl) // regex replace all matches [2026.05, Cypher 25] +``` + +All string functions return `null` when any argument is `null`. + +--- + +## Introspection Functions + +```cypher +labels(n) // LIST of all labels +type(r) // STRING relationship type name +keys(n) // LIST of property keys +properties(n) // MAP of all properties +elementId(n) // STRING internal ID [replaces deprecated id(n) — pre-2025 models generate id()] +``` + +--- + +## FOREACH vs UNWIND + +| Use | When | +|---|---| +| `FOREACH (x IN list \| write-clause)` | Side-effect writes only — no RETURN needed | +| `UNWIND list AS x` | Need to read, filter, or return list items | + +`FOREACH` cannot be followed by `RETURN` or `WITH`. When in doubt, use `UNWIND`. + +```cypher +// FOREACH -- side-effect only +CYPHER 25 +MATCH p = (a:Person {name:'Alice'})-[:KNOWS*1..3]->(b:Person) +FOREACH (n IN nodes(p) | SET n.visited = true) + +// UNWIND -- when you need to process and return +CYPHER 25 +UNWIND $items AS item +WITH item WHERE item.active = true +MERGE (n:Item {id: item.id}) + ON CREATE SET n.name = item.name +RETURN count(n) AS created +``` + +--- + +## OPTIONAL MATCH + +Returns `null` for the optional pattern rather than eliminating the row. + +```cypher +CYPHER 25 +MATCH (p:Person {id: $id}) +OPTIONAL MATCH (p)-[:MANAGES]->(d:Department) +RETURN p.name, d.name AS department // d.name is null when no match + +// Boolean check -- use EXISTS instead of OPTIONAL MATCH +RETURN p.name, EXISTS { (p)-[:MANAGES]->() } AS isManager +``` + +Do NOT chain multiple `OPTIONAL MATCH` for nested optional data — each fan-out multiplies row count. Use `COLLECT {}` instead. + +--- + +## UNION and UNION ALL + +`UNION` deduplicates (slow). `UNION ALL` keeps all rows (fast). Both branches must return identical column names and count. + +```cypher +CYPHER 25 // prefix only on first branch +MATCH (n:Employee) RETURN n.name AS name, n.email AS email +UNION ALL +MATCH (n:Contractor) RETURN n.name AS name, n.email AS email +``` + +`SHOW` commands cannot be combined with `UNION`. Never repeat `CYPHER 25` on subsequent branches. + +--- + +## Spatial / Point + +```cypher +// Create a point (WGS84 geographic) +point({longitude: -122.4194, latitude: 37.7749}) // 2D +point({longitude: -122.4194, latitude: 37.7749, height: 100}) // 3D + +// Create a point (Cartesian) +point({x: 1.5, y: 2.3}) // 2D cartesian +point({x: 1.5, y: 2.3, z: 4.0}) // 3D cartesian + +// Store on node +MATCH (p:Location {id: $id}) +SET p.coords = point({longitude: $lon, latitude: $lat}) + +// Distance in metres +MATCH (a:Location) WHERE a.name = 'HQ' +MATCH (b:Location) +RETURN b.name, point.distance(a.coords, b.coords) AS distM +ORDER BY distM LIMIT 10 + +// Bounding-box filter before distance (uses POINT index) +MATCH (b:Location) +WHERE point.withinBBox(b.coords, + point({longitude: -123.0, latitude: 37.0}), + point({longitude: -122.0, latitude: 38.0})) +RETURN b.name, point.distance(b.coords, $origin) AS distM +``` + +POINT index (required for fast spatial queries): +```cypher +CREATE POINT INDEX location_coords IF NOT EXISTS +FOR (n:Location) ON (n.coords) +``` + +Point components: `.x` / `.y` / `.z` (Cartesian) and `.longitude` / `.latitude` / `.height` (WGS84). + +--- + +## Date and Time + +```cypher +date() // DATE +datetime() // ZONED DATETIME +localdatetime() // LOCAL DATETIME +localtime() // LOCAL TIME + +date('2025-01-15') +datetime('2025-01-15T10:30:00+02:00') +duration({days: 7, hours: 2}) + +n.birthDate.year / .month / .day +n.createdAt.hour / .minute / .second / .timezone + +date() + duration({months: 3}) +duration.between(date1, date2) +date.truncate('month', date()) // first day of current month +``` + +Type rule: `ZONED DATETIME` properties must be compared with `datetime()` literals, not `date()` — mixing types returns 0 rows. + +Duration components: `.years`, `.months`, `.days`, `.hours`, `.minutes`, `.seconds` — `.inDays` / `.inMonths` / `.inSeconds` do NOT exist. + +--- + +## LOAD CSV + +```cypher +// With headers +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///persons.csv' AS row +MERGE (p:Person {id: toInteger(row.id)}) +SET p.name = row.name, p.score = toFloat(row.score) + +// Large files -- always wrap in CALL IN TRANSACTIONS +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///large.csv' AS row +CALL (row) { + MERGE (p:Person {id: row.id}) + SET p += row +} IN TRANSACTIONS OF 1000 ROWS ON ERROR CONTINUE +``` + +All CSV fields are `STRING` — coerce explicitly. `PERIODIC COMMIT` deprecated; use `CALL IN TRANSACTIONS`. + +--- + +## Subqueries [2025.01] + +**Expression subqueries** (auto-import outer variables — no `WITH` needed): + +```cypher +EXISTS { (a)-[:R]->(b) } +EXISTS { MATCH (a)-[:R]->(b) WHERE a.x > 0 } +NOT EXISTS { (a)-[:R]->(b) } +COUNT { (a)-[:R]->(b) WHERE a.x > 0 } +COLLECT { MATCH (a)-[:R]->(b) RETURN b.name } // COLLECT: full MATCH+RETURN required +// COLLECT { (a)-[:R]->(b) } // SYNTAX ERROR -- bare pattern invalid +``` + +`COLLECT {}` returns exactly one column. + +**`CALL` subqueries** — outer variables NOT auto-imported; declare explicitly in `CALL (x) { ... }`: + +```cypher +CYPHER 25 +MATCH (p:Person) +CALL (p) { + MATCH (p)-[:ACTED_IN]->(m:Movie) + RETURN count(m) AS movieCount +} +RETURN p.name, movieCount +// CALL (*) imports all outer variables; CALL () imports nothing +// CALL { WITH x ... } deprecated [pre-2025 form] -- use CALL (x) { ... } [2025.01] +``` + +| Goal | Use | +|---|---| +| Boolean existence check | `EXISTS { (a)-[:R]->(b) }` | +| Count matching subgraph | `COUNT { (a)-[:R]->(b) }` | +| Collect related items into a list | `COLLECT { MATCH (a)-[:R]->(b) RETURN b.name }` | +| Nullable join | `OPTIONAL MATCH` (simple) or `OPTIONAL CALL` (complex) | +| Subquery with own aggregation or writes | `CALL (x) { ... }` | + +--- + +## Quantified Path Expressions (QPEs) [2025.01 — replaces shortestPath()/allShortestPaths() and `[:R*m..n]` syntax] + +```cypher +// Reachability: 1-3 hops with relationship predicate +CYPHER 25 +MATCH (start:Person {name: 'Alice'}) + (()-[rel:KNOWS WHERE rel.since > date('2024-01-01')]->(:Person)){1,3} + (end) +WITH DISTINCT end +RETURN end.name + +// Inner variables become lists -- access with list comprehension +CYPHER 25 +MATCH (src:Person {name: 'Alice'}) + ((n:Person)-[:KNOWS]->()){1,3}(dst:Person) +RETURN [x IN n | x.name] AS via, dst.name AS reached +``` + +Syntax rules: +- Prefer `{1,}` over `+`, `{0,}` over `*` +- Quantifier goes **outside** the group: `(pattern){N,M}` +- Groups must start AND end with a node + +Match modes [2025.01] (immediately after `MATCH`): + +| Mode | Semantics | +|---|---| +| `DIFFERENT RELATIONSHIPS` | Default — each relationship traversed at most once per path | +| `REPEATABLE ELEMENTS` | Nodes AND relationships may be revisited; requires bounded `{m,n}` | +| `ACYCLIC` [2026.03] | No repeated nodes within a path; GQL path mode — prevents cycles | + +`ACYCLIC` is placed before the path pattern: `MATCH p = ACYCLIC (a)-[:R]-+(b)`. +Nodes cannot repeat within a path; may still repeat across paths (equijoins work). + +Path selectors (immediately after `MATCH`, before the pattern): + +| Selector | Semantics | +|---|---| +| `SHORTEST 1` | One shortest path | +| `ALL SHORTEST` | All shortest paths of equal minimum length | +| `ANY` | Any single path (no length guarantee) | +| `SHORTEST k GROUPS` | All paths grouped by length up to k distinct lengths | + +Path modes combine with shortest selectors [2026.05]: `MATCH ANY SHORTEST ACYCLIC (a)-[:R]-+(b)` — `ACYCLIC` valid with `ANY SHORTEST`, `SHORTEST k`, `ALL SHORTEST`, `SHORTEST k GROUPS`. + +```cypher +CYPHER 25 MATCH SHORTEST 1 (a:Person {name:'Alice'})(()-[:KNOWS]->()){1,}(b:Person {name:'Bob'}) +RETURN b.name +``` + +--- + +## Dynamic Labels and Properties [2025.01] + +```cypher +// Filter by dynamic label +CYPHER 25 +MATCH (n) +WHERE n:$($label) +RETURN n + +// Set label dynamically +CYPHER 25 +MATCH (n:Pending) +SET n:$(n.category) + +// Dynamic property key -- bracket notation required +CYPHER 25 +MATCH (n:Config) +RETURN n[$key] + +MATCH (n:Config {id: $id}) +SET n[$key] = $value +// DON'T: SET n.$key = $value // SyntaxError + +// Copy properties between elements +SET n = properties(r) +// DON'T: SET n = r // TypeError -- assigns reference, not properties +``` + +--- + +## SEARCH Clause (Vector/Fulltext Search) [2026.01] + +```cypher +// Node vector index +CYPHER 25 +MATCH (c:Chunk) +SEARCH c IN (VECTOR INDEX news FOR $embedding LIMIT 10) +SCORE AS score +WHERE score > 0.8 +RETURN c.text, score +ORDER BY score DESC + +// Procedure fallback (pre-2026.01): +CYPHER 25 CALL db.index.vector.queryNodes('news', 10, $embedding) YIELD node AS c, score RETURN c.text, score + +// Fulltext -- always use procedure regardless of version: +CYPHER 25 CALL db.index.fulltext.queryNodes('entity', $query) YIELD node, score RETURN node.name, score LIMIT 20 +``` + +SEARCH syntax: binding variable only (not `(c)`); `LIMIT` inside parens; `SCORE AS` after closing paren. + +--- + +## CALL IN TRANSACTIONS (write batching only) [2025.01: CONCURRENT, REPORT STATUS added; PERIODIC COMMIT removed] + +Input stream must be **outside** the subquery — filtering inside collapses everything into one transaction. + +```cypher +// Basic batch update +CYPHER 25 +MATCH (c:Customer) +CALL (c) { + SET c.flag = 'done' +} IN TRANSACTIONS OF 1000 ROWS +RETURN count(c) + +// With error handling and status reporting +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row +CALL (row) { + MERGE (p:Person {id: row.id}) + ON CREATE SET p.name = row.name +} IN TRANSACTIONS OF 500 ROWS + ON ERROR CONTINUE + REPORT STATUS AS s +WITH s WHERE s.errorMessage IS NOT NULL +RETURN s.transactionId, s.errorMessage + +// Parallel batches +CYPHER 25 +UNWIND $rows AS row +CALL (row) { + MERGE (:Movie {id: row.id}) +} IN 4 CONCURRENT TRANSACTIONS OF 10 ROWS + ON ERROR CONTINUE +``` + +`IN TRANSACTIONS` comes **after** the `{ }` block. Read-only use prohibited. Requires auto-commit — do not wrap in `beginTransaction()`. + +**ON ERROR options**: `FAIL` (default) | `CONTINUE` (skip failed batch) | `BREAK` (stop after first error) | `RETRY FOR N SECS` [2025.03+] + +--- + +## Conditional CALL Subqueries (WHEN…THEN…ELSE) [2025.06 / Neo4j 2025.06+] + +If-else-if semantics in a single subquery block. Replaces multiple independent `CALL` blocks or complex `CASE` with side effects. + +```cypher +// Move a linked-list item: insert before/after depending on context +CYPHER 25 +MATCH (move:Item {id: $id}) +OPTIONAL MATCH (insertBefore:Item {id: $before}) +OPTIONAL MATCH (insertAfter:Item {id: $after}) +CALL (move, insertBefore, insertAfter) { + WHEN insertBefore IS NULL THEN { + MATCH (last:Item) WHERE NOT (last)-[:NEXT]->() AND last <> move + CREATE (last)-[:NEXT]->(move) + } + WHEN insertAfter IS NULL THEN { + CREATE (move)-[:NEXT]->(insertBefore) + } + ELSE { + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) + } +} +``` + +Rules: +- Branches receive only params declared in `CALL(params)` +- Mutually exclusive — first matching WHEN wins +- Each branch can contain full write clauses; `ELSE` is optional +- Cannot mix `WHEN...THEN` and regular subquery body in same `CALL` + +--- + +## Label Pattern Expressions [Neo4j 5+] + +Boolean logic on labels using `|` (OR), `&` (AND), `!` (NOT): + +```cypher +// Nodes with label A OR B +MATCH (n:Person|Organization) RETURN n + +// Nodes with label A AND B +MATCH (n:Employee&Manager) RETURN n + +// Nodes with label A but NOT B +MATCH (n:Person&!VIP) RETURN n + +// Complex expression +MATCH (n:Marvel|(DCComics&!Batman)) RETURN n +``` + +Dynamic label quantifiers in MATCH (require `$()` wrapper) [2025.01]: +```cypher +// Node must have ALL labels in the list +MATCH (n:$all($labelList)) RETURN n + +// Node must have ANY label in the list +MATCH (n:$any($labelList)) RETURN n +``` + +--- + +## Compact CASE WHEN [Neo4j 5+] + +Multiple values and comparison operators in a single WHEN branch. + +```cypher +// Multiple values in WHEN (simple CASE) +MATCH (n:Event) +RETURN CASE n.dayOfWeek + WHEN 1, 7 THEN 'weekend' + WHEN 2, 3, 4, 5, 6 THEN 'weekday' + ELSE 'unknown' +END AS dayType + +// Comparison operators in WHEN (generic CASE) +RETURN CASE n.age + WHEN > 65 THEN 'senior' + WHEN > 18 THEN 'adult' + WHEN < 0 THEN 'invalid' + ELSE 'minor' +END AS ageGroup +``` + +--- + +## String Normalization [Neo4j 5+] + +`normalize(s)` converts to NFC Unicode — solves accented character comparison where identical glyphs have different code points: + +```cypher +// Match regardless of Unicode encoding differences (e.g., 'ö' as U+00F6 vs o + combining diacritic) +MATCH (c:City) +WHERE normalize(c.name) = normalize($cityName) +RETURN c + +// Index on normalized form for consistent lookups +CREATE RANGE INDEX city_name IF NOT EXISTS FOR (c:City) ON (c.normalizedName) +MATCH (c:City) SET c.normalizedName = normalize(c.name) +``` + +--- + +## allReduce Function (Traversal State) [CYPHER 25] + +Accumulates state during QPE traversal — mid-traversal filtering and stateful path constraints. Prunes invalid paths inline instead of post-filtering. + +```cypher +// Syntax: allReduce(accumulator = initial, var IN list | updateExpr, predicate) +// Returns true only if predicate holds for every intermediate accumulator value + +// Example: track visited small nodes, require no revisits +CYPHER 25 +MATCH REPEATABLE ELEMENTS path = (:Start)((xs:!End)--(:!Start)){0,100}(e:End) +WHERE allReduce( + visited = [], + x IN xs | CASE WHEN x:Big THEN visited ELSE visited + [x] END, + size(visited) <= size(apoc.coll.toSet(visited)) + 1 +) +RETURN count(path) + +// Example: stateful battery charge simulation during route traversal +CYPHER 25 runtime=parallel +MATCH REPEATABLE ELEMENTS p=(a:Geo {name: $src})(()-[r:ROAD|CHARGE]-(x:Geo)){1,12}(b:Geo {name: $dst}) +WHERE allReduce( + curr = {soc: $initial_soc, mins: 0.0}, + r IN relationships(p) | + CASE + WHEN r:ROAD THEN {soc: curr.soc - r.drain, mins: curr.mins + r.drive_mins} + WHEN r:CHARGE THEN {soc: curr.soc + r.charge, mins: curr.mins + r.charge_mins} + END, + $min_soc <= curr.soc <= $max_soc AND curr.mins <= $max_mins +) +RETURN p, reduce(d=0, r IN relationships(p) | d + r.drive_mins) AS total_mins +ORDER BY total_mins LIMIT 1 +``` + +`allReduce` is evaluated inline during path expansion — prunes branches early rather than filtering after full traversal. + +--- + +## NEXT Clause [CYPHER 25] + +Chains query blocks without re-traversal; each block adds computed columns: + +```cypher +CYPHER 25 +MATCH (a:Airport {iata: $src})-[r:FLIGHT]->(b:Airport {iata: $dst}) +RETURN a, b, r +NEXT +RETURN a, b, r, r.duration + r.layover AS totalTime +ORDER BY totalTime ASC LIMIT 5 +``` diff --git a/.agents/skills/neo4j-cypher-skill/references/graph-type.md b/.agents/skills/neo4j-cypher-skill/references/graph-type.md new file mode 100644 index 0000000000..db1ae77e2d --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/graph-type.md @@ -0,0 +1,162 @@ +# GRAPH TYPE — Schema Enforcement DDL + +> **PREVIEW feature — Neo4j 2026.02+** +> Enterprise Edition, Infinigraph Edition, all Aura tiers. +> Syntax may change before GA. Not supported for production use. +> Feedback: graphtype@neo4j.com + +GRAPH TYPE consolidates what previously required dozens of individual `CREATE CONSTRAINT` / `CREATE INDEX` statements into a single declarative schema definition. It operates on an **open model**: validation applies only to defined elements; extra labels and properties on nodes/relationships are still allowed. + +Underlying constraints generated by GRAPH TYPE are visible via `SHOW CONSTRAINTS`. + +--- + +## Lifecycle Commands + +| Command | Purpose | +|---|---| +| `SHOW CURRENT GRAPH TYPE` | Display the full enforced schema as a string | +| `SHOW CURRENT GRAPH TYPE AS GRAPH` | [2026.06] Return schema as virtual nodes/relationships instead of a string | +| `ALTER CURRENT GRAPH TYPE SET { … }` | Replace/initialise the graph type (full redeclaration required) | +| `EXTEND GRAPH TYPE WITH { … }` | Add new elements without touching existing ones | +| `DROP GRAPH TYPE ELEMENTS { … }` | Remove schema enforcement; data is preserved | + +--- + +## Syntax + +### Define or replace the full schema + +```cypher +CYPHER 25 +ALTER CURRENT GRAPH TYPE SET { + (person:Person { + id :: INTEGER NOT NULL, + name :: STRING NOT NULL, + born :: INTEGER + }) REQUIRE person.id IS KEY, + + (movie:Movie { + movieId :: STRING NOT NULL, + title :: STRING NOT NULL, + released :: INTEGER + }) REQUIRE movie.movieId IS KEY, + + // Label implication: every Crew node must also be a Person + (crew:Crew => :Person), + + // Relationship type with enforced source and target node types + (:Person)-[:ACTED_IN { roles :: LIST }]->(:Movie), + (:Person)-[:DIRECTED]->(:Movie), + (:Person)-[:KNOWS { since :: DATE }]->(:Person) +} +``` + +### Extend incrementally + +```cypher +CYPHER 25 +EXTEND GRAPH TYPE WITH { + (producer:Producer { + name :: STRING NOT NULL + }) REQUIRE producer.name IS UNIQUE, + + (:Producer)-[:PRODUCED]->(:Movie) +} +``` + +### Drop specific elements + +```cypher +CYPHER 25 +DROP GRAPH TYPE ELEMENTS { + (:Producer)-[:PRODUCED]->(:Movie) +} +``` + +### Inspect current schema + +```cypher +CYPHER 25 +SHOW CURRENT GRAPH TYPE +``` + +--- + +## Property Types + +| Type keyword | Notes | +|---|---| +| `STRING` | UTF-8 string | +| `INTEGER` | 64-bit signed integer | +| `FLOAT` | 64-bit float | +| `BOOLEAN` | true / false | +| `DATE` | Calendar date | +| `ZONED DATETIME` | Datetime with timezone | +| `LOCAL DATETIME` | Datetime without timezone | +| `DURATION` | ISO 8601 duration | +| `POINT` | Spatial point | +| `LIST` | Homogeneous list (e.g. `LIST`) | + +Append `NOT NULL` to prohibit null values: `name :: STRING NOT NULL` + +--- + +## Constraints Within GRAPH TYPE + +| Syntax | Equivalent standalone constraint | +|---|---| +| `REQUIRE node.prop IS KEY` | `CREATE CONSTRAINT … REQUIRE n.prop IS NODE KEY` | +| `REQUIRE node.prop IS UNIQUE` | `CREATE CONSTRAINT … REQUIRE n.prop IS UNIQUE` | +| `prop :: TYPE NOT NULL` (inside node def) | `CREATE CONSTRAINT … REQUIRE n.prop IS NOT NULL` | + +--- + +## Label Implications + +```cypher +(crew:Crew => :Person) // every :Crew node must also have :Person label +(admin:Admin => :Person:Staff) // multiple implied labels +``` + +The implication is enforced on write — Neo4j rejects or auto-adds the implied label depending on configuration. + +--- + +## Relationship Type Enforcement + +Specifying source and target node types constrains which nodes may participate: + +```cypher +(:Person)-[:KNOWS { since :: DATE }]->(:Person) +``` + +Attempting to create a `:KNOWS` relationship from a `:Movie` to a `:Person` will be rejected. + +--- + +## When to Use GRAPH TYPE vs Individual Constraints + +| Scenario | Recommendation | +|---|---| +| Greenfield project on 2026.02+, Enterprise/Aura | Use GRAPH TYPE — single source of truth for the schema | +| Existing database with many constraints already | Migrate incrementally with `EXTEND GRAPH TYPE WITH` | +| Neo4j < 2026.02 or Community Edition | Use `CREATE CONSTRAINT IF NOT EXISTS` per property | +| Production workload requiring stability | Wait for GA — PREVIEW syntax may change | + +--- + +## Fallback (pre-2026.02) + +```cypher +CREATE CONSTRAINT IF NOT EXISTS FOR (n:Person) REQUIRE n.id IS NODE KEY; +CREATE CONSTRAINT IF NOT EXISTS FOR (n:Movie) REQUIRE n.movieId IS UNIQUE; +CREATE CONSTRAINT IF NOT EXISTS FOR (n:Person) REQUIRE n.name IS NOT NULL; +``` + +--- + +## References + +- Docs: `https://neo4j.com/docs/cypher-manual/current/schema/graph-types/` +- Blog: `https://neo4j.com/blog/developer/graph-type-schema-enforcement-made-easy-preview/` diff --git a/.agents/skills/neo4j-cypher-skill/references/indexes.md b/.agents/skills/neo4j-cypher-skill/references/indexes.md new file mode 100644 index 0000000000..a01b5b8c57 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/indexes.md @@ -0,0 +1,349 @@ +# Neo4j Indexes and Constraints + +## Why indexes are critical + +Every `MATCH`, `MERGE`, or `WHERE` predicate on a node/relationship property requires an index on the **initial lookup property** (the anchor that starts traversal). Without one, Neo4j does a full AllNodesScan or AllRelationshipsScan. + +**Index requires a label.** Without a label, Neo4j cannot identify which index to use and falls back to full scan even if an index exists. + +```cypher +// IGNORED: no label → no index used, full scan +MATCH (n {email: $email}) RETURN n.name, n.email + +// USED: label present → RANGE/UNIQUE index on Person.email +MATCH (n:Person {email: $email}) RETURN n.name, n.email + +// IGNORED in MERGE too: label required +MERGE (n {email: $email}) // full scan, no lock +MERGE (n:Person {email: $email}) // index lookup + constraint lock +``` + +MERGE compounds this: `MERGE (n:Person {email: $email})` = `MATCH` + `CREATE IF NOT EXISTS`. MATCH phase scans without an index. With a constraint, MERGE also acquires a lock on the constraint entry, preventing concurrent duplicate creation. + +**Single index per MATCH clause by default.** Planner picks one anchor index for multi-predicate queries. Use `USING INDEX` hints to force multiple indexes in the same MATCH. + +--- + +## Index type decision table + +| Query predicate | Index type | Notes | +|---|---|---| +| `prop = $val`, `prop > $val`, `prop < $val`, `prop >= $val`, `prop <= $val` | **RANGE** | Numbers, dates, booleans, strings | +| `prop STARTS WITH $val` | **RANGE** | Also supported by TEXT but RANGE is faster for prefix | +| `prop CONTAINS $val`, `prop ENDS WITH $val` | **TEXT** | Uses trigram (text-2.0); RANGE does NOT support these efficiently | +| `prop IN [$a, $b]` (string list) | **TEXT** | Faster than RANGE for string list membership | +| `prop IS NOT NULL` | **RANGE** | Existence check with range index | +| `point.distance(n.loc, $pt) < $r`, `point.withinBBox(...)` | **POINT** | Spatial queries | +| Full-text search, multiple labels/props, fuzzy, Lucene syntax | **FULLTEXT** | Returns score; not a filter index | +| `(n:Label)` or `()-[r:TYPE]-()` without property | **LOOKUP** | Always exists; covers label/type scans | +| `vector.similarity.*`, `SEARCH ... VECTOR INDEX` | **VECTOR** | See `neo4j-vector-index-skill` | +| Multiple props on same label in AND | **COMPOSITE** | All composite props must appear in WHERE | + +--- + +## Index providers (internal implementations) + +| Index type | Default provider | Notes | +|---|---|---| +| RANGE / UNIQUE / NODE KEY / COMPOSITE | `range-1.0` | B-tree variant; all scalar types | +| TEXT | `text-2.0` | Trigram-based — see section below | +| FULLTEXT | `fulltext-1.0` | Apache Lucene (`lucene+native-3.0`) | + +LOOKUP indexes (auto-created, two per database) have no user-configurable provider. + +--- + +## TEXT index — trigram internals + +Default `text-2.0` indexes STRING values as overlapping **trigrams** (3-Unicode-codepoint windows). Example: `"developer"` → `["dev","eve","vel","elo","lop","ope","per"]`. + +- `CONTAINS "vel"` / `ENDS WITH "per"` → direct trigram lookup, O(1) index probe. +- `STARTS WITH` works via trigram but RANGE is faster for prefix-only. +- When both RANGE and TEXT exist on the same STRING property, planner **auto-selects TEXT** for `CONTAINS`/`ENDS WITH`, RANGE for `STARTS WITH`/`=`/range predicates. +- TEXT takes **less storage** than RANGE for high-cardinality string data. +- TEXT may show **higher db-hits but lower elapsed time** vs RANGE for substring queries — measure elapsed ms, not db-hits. +- `text-1.0` (pre-5.1) does NOT use trigrams — deprecated. + +--- + +## Create syntax + +```cypher +// RANGE (numbers, dates, booleans, strings: =, >, <, STARTS WITH, IS NOT NULL) +CREATE RANGE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email) + +// RANGE on relationship +CREATE RANGE INDEX event_date IF NOT EXISTS FOR ()-[r:OCCURRED_ON]-() ON (r.date) + +// RANGE composite (all listed props must appear in WHERE for planner to use it) +CREATE INDEX person_name_age IF NOT EXISTS FOR (n:Person) ON (n.name, n.age) + +// RANGE composite on relationship +CREATE INDEX purchased_date_amount IF NOT EXISTS FOR ()-[r:PURCHASED]-() ON (r.date, r.amount) + +// TEXT (string CONTAINS, ENDS WITH, IN list — trigram internally) +CREATE TEXT INDEX person_name_text IF NOT EXISTS FOR (n:Person) ON (n.name) + +// TEXT on relationship +CREATE TEXT INDEX rates_interest IF NOT EXISTS FOR ()-[r:KNOWS]-() ON (r.interest) + +// POINT (spatial) +CREATE POINT INDEX place_location IF NOT EXISTS FOR (n:Place) ON (n.location) + +// POINT with spatial bounding box config (WGS-84 geographic CRS) +CREATE POINT INDEX place_wgs IF NOT EXISTS FOR (n:Place) ON (n.location) +OPTIONS { + indexConfig: { + `spatial.wgs-84.min`: [-180.0, -90.0], + `spatial.wgs-84.max`: [180.0, 90.0] + } +} +// Other spatial CRS config keys: spatial.cartesian.min/max, spatial.cartesian-3d.min/max, spatial.wgs-84-3d.min/max + +// FULLTEXT (Lucene; multi-label, multi-prop, scored) +CREATE FULLTEXT INDEX search_articles IF NOT EXISTS + FOR (n:Article|BlogPost) ON EACH [n.title, n.body] + +// FULLTEXT with analyzer + eventually-consistent background updates +CREATE FULLTEXT INDEX search_articles IF NOT EXISTS + FOR (n:Article|BlogPost) ON EACH [n.title, n.body] +OPTIONS { + indexConfig: { + `fulltext.analyzer`: 'english', + `fulltext.eventually_consistent`: true + } +} + +// LOOKUP (auto-created per database — shown for reference only; do NOT drop or recreate) +CREATE LOOKUP INDEX node_label_lookup FOR (n) ON EACH labels(n) +CREATE LOOKUP INDEX rel_type_lookup FOR ()-[r]-() ON EACH type(r) +``` + +### FULLTEXT analyzer options + +| Analyzer | Use case | +|---|---| +| `standard-no-stop-words` | Default — general purpose, removes stop words | +| `english` | English stemming (run/runs/running → same token) | +| `simple` | Lowercase only, no stemming | +| Custom (Java SPI) | Implement `AnalyzerProvider` interface | + +`fulltext.eventually_consistent: true` — index updated in background. Improves write throughput at cost of slight search lag. + +--- + +## Constraints + +Enforce data integrity AND create an implicit **RANGE index** (UNIQUE, NODE KEY). Prefer constraint over bare index when uniqueness is required. + +**Edition notes**: UNIQUE and NOT NULL available in all editions. NODE KEY, RELATIONSHIP KEY, RELATIONSHIP UNIQUE, property type (`IS ::`) require **Enterprise Edition**. + +```cypher +// UNIQUE node — creates implicit RANGE index; MERGE acquires lock +CREATE CONSTRAINT person_email_unique IF NOT EXISTS + FOR (n:Person) REQUIRE n.email IS UNIQUE + +// UNIQUE composite node +CREATE CONSTRAINT book_title_year IF NOT EXISTS + FOR (n:Book) REQUIRE (n.title, n.publicationYear) IS UNIQUE + +// UNIQUE relationship (Enterprise) +CREATE CONSTRAINT sequel_order IF NOT EXISTS + FOR ()-[r:SEQUEL_OF]-() REQUIRE r.order IS UNIQUE + +// NODE KEY — composite uniqueness + existence; creates composite RANGE index (Enterprise) +CREATE CONSTRAINT person_key IF NOT EXISTS + FOR (n:Person) REQUIRE (n.firstName, n.lastName) IS NODE KEY + +// RELATIONSHIP KEY (Enterprise) +CREATE CONSTRAINT owns_key IF NOT EXISTS + FOR ()-[r:OWNS]-() REQUIRE r.ownershipId IS RELATIONSHIP KEY + +// NOT NULL node (existence only — no index created) +CREATE CONSTRAINT person_name_exists IF NOT EXISTS + FOR (n:Person) REQUIRE n.name IS NOT NULL + +// NOT NULL relationship +CREATE CONSTRAINT wrote_year_exists IF NOT EXISTS + FOR ()-[r:WROTE]-() REQUIRE r.year IS NOT NULL + +// PROPERTY TYPE node (Enterprise) — IS ::, IS TYPED, and :: are synonyms; IS :: is preferred +CREATE CONSTRAINT movie_title_type IF NOT EXISTS + FOR (n:Movie) REQUIRE n.title IS :: STRING + +// PROPERTY TYPE relationship (Enterprise) +CREATE CONSTRAINT rating_type IF NOT EXISTS + FOR ()-[r:RATED]-() REQUIRE r.rating IS :: INTEGER +``` + +Supported types for `IS ::`: `BOOLEAN`, `STRING`, `INTEGER`, `FLOAT`, `DATE`, `LOCAL TIME`, `ZONED TIME`, `LOCAL DATETIME`, `ZONED DATETIME`, `DURATION`, `POINT`, `LIST`. + +--- + +## MERGE and constraints + +`MERGE` = `MATCH` + conditional `CREATE`. Without an index/constraint on the merge property, MATCH scans all nodes of that label. + +```cypher +// Without constraint: full scan + no atomicity guarantee +MERGE (p:Person {email: $email}) + +// With UNIQUE constraint: +// 1. O(log n) lookup via implicit RANGE index +// 2. Lock on constraint entry → prevents concurrent duplicate creation +// 3. Atomic: two concurrent MERGEs cannot both create the same node +CREATE CONSTRAINT person_email_unique IF NOT EXISTS + FOR (n:Person) REQUIRE n.email IS UNIQUE + +MERGE (p:Person {email: $email}) + ON CREATE SET p.createdAt = datetime() + ON MATCH SET p.lastSeenAt = datetime() +``` + +Merge on multiple properties without NODE KEY: planner may not use index. +Merge only on the indexed property, set others after: `MERGE (n:Label {keyProp: $val}) SET n.otherProp = $other` + +--- + +## Fulltext search + +Lucene — tokenized, scored, not a filter index. Result nodes must be joined back to the graph. Supports `LIST` properties — each element analyzed independently. + +```cypher +// Create (multi-label, multi-prop) +CREATE FULLTEXT INDEX article_search IF NOT EXISTS + FOR (n:Article|BlogPost) ON EACH [n.title, n.body] + +// Query nodes — returns node + score (descending) +CALL db.index.fulltext.queryNodes('article_search', 'graph database') +YIELD node, score +WHERE score > 0.5 +RETURN node.title, score +ORDER BY score DESC LIMIT 10 + +// Query relationships +CALL db.index.fulltext.queryRelationships('rel_search', 'query string') +YIELD relationship, score +RETURN relationship, score + +// Lucene query syntax: +// 'graph database' token AND (default) +// '"graph database"' exact phrase +// 'graph OR database' OR +// 'graph -relational' NOT +// 'graph~' fuzzy +// 'graph*' wildcard prefix +// 'title:graph' field-scoped search +// 'team:"Operations"' field + exact phrase +``` + +Fulltext index does NOT participate in WHERE predicate planning. Use `CALL db.index.fulltext.queryNodes` / `queryRelationships` explicitly. + +--- + +## Index hints (USING INDEX) + +Force a specific index when the planner chooses a suboptimal plan. Use `EXPLAIN` first to confirm the issue. + +```cypher +// Generic hint — planner uses any available index on the property +MATCH (p:Person) +USING INDEX p:Person(email) +WHERE p.email = $email +RETURN p.name, p.email + +// Force RANGE index specifically +MATCH (s:Scientist {born: 1850}) +USING RANGE INDEX s:Scientist(born) +RETURN s.name, s.born + +// Force TEXT index specifically +MATCH (c:Country) +USING TEXT INDEX c:Country(name) +WHERE c.name = 'Country7' +RETURN c.name, c.population + +// Two hints in one query — forces both path ends to use their index (enables index join) +MATCH (p:Person)-[:ACTED_IN]->(m:Movie)<-[:DIRECTED]-(p2:Person) +USING INDEX p:Person(name) +USING INDEX p2:Person(name) +WHERE p.name CONTAINS 'John' AND p2.name CONTAINS 'George' +RETURN p.name, p2.name, m.title + +// Relationship index hint +MATCH (u:User)-[r:RATED]->(m:Movie) +USING INDEX r:RATED(rating) +WHERE r.rating = 5 +RETURN u.name, r.rating, m.title + +// Relationship TEXT index hint +MATCH (n:Inventor)-[i:INVENTED_BY]->(inv:Invention) +USING TEXT INDEX i:INVENTED_BY(location) +WHERE i.location = 'Location7' +RETURN n.name, inv.name, i.location +``` + +Rules: +- Typed hints (`USING RANGE INDEX`, `USING TEXT INDEX`) only valid when the planner can guarantee the type doesn't change results. +- Hints do NOT guarantee improvement — PROFILE before/after; measure elapsed ms (not db-hits for TEXT). +- Index **not used** when predicate compares two node properties (`WHERE p.name = p2.name`) — no anchor. +- FULLTEXT has no `USING INDEX` hint — call `db.index.fulltext.queryNodes` explicitly. +- Check query stats first (`CALL db.stats.retrieve('GRAPH COUNTS')`) before adding hints. + +--- + +## Inspect indexes and constraints + +```cypher +// All indexes — core fields +SHOW INDEXES YIELD name, type, state, labelsOrTypes, properties, populationPercent + WHERE state <> 'ONLINE' OR populationPercent < 100 // building or not ready + +// Full details (includes: indexSize, lastRead, readCount, lastWrite, writeCount, indexConfig) +SHOW INDEXES YIELD * + +// Filter by type +SHOW RANGE INDEXES YIELD name, state, labelsOrTypes, properties +SHOW TEXT INDEXES YIELD name, state, labelsOrTypes, properties +SHOW FULLTEXT INDEXES YIELD name, state, indexConfig +SHOW VECTOR INDEXES YIELD name, state, populationPercent, indexConfig +SHOW LOOKUP INDEXES YIELD name, state + +// Unused index candidates (never read — review for removal) +SHOW INDEXES YIELD name, type, readCount, lastRead + WHERE readCount = 0 AND type <> 'LOOKUP' + RETURN name, type, lastRead + ORDER BY lastRead + +// Constraints +SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties + +// Check index used in query plan +EXPLAIN MATCH (p:Person {email: $email}) RETURN p +// 'NodeIndexSeek' or 'NodeUniqueIndexSeek' — index used ✓ +// 'NodeIndexContainsScan' — TEXT index via CONTAINS ✓ +// 'NodeByLabelScan' or 'AllNodesScan' — no index, add one + +// PROFILE for timing (run twice; second run = true cost) +PROFILE MATCH (p:Person) WHERE p.name CONTAINS 'Robert' RETURN p.name +``` + +--- + +## Import pre-flight — create before loading + +Create constraints and indexes **before** bulk import — MERGE during load uses the index for every row. + +```cypher +// 1. Uniqueness constraints first (implicit RANGE index) +CREATE CONSTRAINT person_id IF NOT EXISTS FOR (n:Person) REQUIRE n.id IS UNIQUE; +CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (n:Movie) REQUIRE n.id IS UNIQUE; +CREATE CONSTRAINT org_name IF NOT EXISTS FOR (n:Org) REQUIRE n.name IS UNIQUE; + +// 2. Additional lookup indexes (non-unique properties used in MATCH/WHERE) +CREATE RANGE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email); +CREATE TEXT INDEX movie_title IF NOT EXISTS FOR (n:Movie) ON (n.title); + +// 3. Wait for all to be ONLINE before loading +SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE' RETURN name, state; +``` diff --git a/.agents/skills/neo4j-cypher-skill/references/performance.md b/.agents/skills/neo4j-cypher-skill/references/performance.md new file mode 100644 index 0000000000..5409014051 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/performance.md @@ -0,0 +1,159 @@ +# Performance Anti-Patterns + +Load this when optimizing a slow query or reviewing a query before production use. + +## Anti-Patterns + +Severity: **[ALWAYS]** fix unconditionally. **[USUALLY]** fix unless confirmed reason not to. **[SITUATIONAL]** profile first. + +| Anti-Pattern | Severity | Problem | Fix | +|---|---|---|---| +| `MATCH (n)` label-free | [ALWAYS] | AllNodesScan | Add label: `MATCH (n:Person)` — indexes require a label | +| `MATCH ()-[r]->()` label-free rel | [ALWAYS] | Full rel scan | `MATCH (n:User)-[r:POSTS]->()` | +| Assumed stored GDS props (`n.pageRank`) | [ALWAYS] | Property doesn't exist unless `.write` ran | Stream via `.stream` procedure | +| `CONTAINS`/`ENDS WITH` without a text index | [ALWAYS] | Range index does not support these; causes full label scan | `CREATE TEXT INDEX idx FOR (n:Label) ON (n.prop)` | +| `MATCH (u)-[:R]->(t1), (u)-[:R]->(t2) WHERE t1 <> t2` | [USUALLY] | O(n²) pairs | `collect(t) AS items WHERE size(items) >= 2` | +| `UNWIND list AS a UNWIND list AS b WHERE a <> b` | [USUALLY] | O(n²) pairs | `LIMIT` before pairing, or sample `list[0..10]` | +| Chained `OPTIONAL MATCH` for nested optional data | [USUALLY] | Fan-out multiplies row count | `COLLECT { MATCH (a)-[:R]->(b) RETURN b }` | +| `LIMIT` only at final `RETURN` | [USUALLY] | Full traversal runs before limit | Push `WITH n LIMIT 100` before expensive joins | +| Cartesian product (two MATCHes, no join) | [USUALLY] | Multiplies all rows | Add join predicate in `WHERE` | + +→ See [indexes.md](indexes.md) for index type selection, MERGE lock semantics, hints, and `SHOW INDEXES YIELD *`. + +## Text indexes vs fulltext indexes + +| Index type | Supports | Created with | Queried with | +|---|---|---|---| +| Text index | `CONTAINS`, `ENDS WITH` | `CREATE TEXT INDEX idx FOR (n:Label) ON (n.prop)` | Standard `WHERE` + optional hint | +| Fulltext index | Lucene tokenized search with scoring | `CREATE FULLTEXT INDEX idx FOR (n:Label1\|Label2) ON EACH [n.prop1, n.prop2]` | `CALL db.index.fulltext.queryNodes('idx', $query)` | + +```cypher +// Text index +CREATE TEXT INDEX person_bio FOR (n:Person) ON (n.bio) +MATCH (n:Person) USING TEXT INDEX n:Person(bio) WHERE n.bio CONTAINS $s RETURN n + +// Fulltext index +CREATE FULLTEXT INDEX entity FOR (n:Person|Company) ON EACH [n.name, n.description] +CALL db.index.fulltext.queryNodes('entity', $query) YIELD node, score +RETURN node.name, score ORDER BY score DESC LIMIT 20 +``` + +EXPLAIN / PROFILE red flags: `AllNodesScan`, `CartesianProduct`, `NodeByLabelScan`, `Eager`. + +For analytics over large sets: +```cypher +CYPHER 25 runtime=parallel +MATCH (n:Article) +RETURN count(n), avg(n.sentiment) +``` + +Confirm with EXPLAIN — header must show `Runtime PARALLEL`. Only for large analytical scans; adds overhead for OLTP short-hop lookups. + +## Eager Operator + +`Eager` materializes entire intermediate result in memory. Blocks streaming; causes heap pressure at scale. + +**Common triggers:** + +| Pattern | Why Eager appears | Fix | +|---|---|---| +| `MATCH (n:A) ... MERGE (:A {...})` | MERGE on same label as MATCH | collect first, then UNWIND+write | +| `UNWIND list MERGE (a:X) MERGE (b:X)` | Two MERGEs on same label in one row | `CALL IN TRANSACTIONS` | +| `MATCH (n:A) CREATE (m:A)` | CREATE on same label as MATCH | collect first | +| `FOREACH (x IN list \| CREATE (:A))` | Write inside FOREACH visible to outer read | `UNWIND` + write | +| `MATCH (n:A)-[]-(m) MERGE (:A {name:'London'})` | Ambiguous label scope | Add specific label to MATCH nodes | + +**Fix 1: Add specific labels to disambiguate** [official — LP Eagerness planner] +```cypher +// BEFORE -- Eager: planner can't tell if new :City hits :LondonGroup MATCH +MATCH (station:LondonGroup)<-[:CALLS_AT]-(london_calling) +MERGE (london_calling)-[:CALLS_AT_CITY]->(city:City {name: 'London'}) + +// AFTER -- label :CallingPoint eliminates ambiguity; Eager removed +MATCH (station:LondonGroup)<-[:CALLS_AT]-(london_calling:CallingPoint) +MERGE (london_calling)-[:CALLS_AT_CITY]->(city:City {name: 'London'}) +``` + +**Fix 2: collect first, then write** +```cypher +// BEFORE -- triggers Eager +MATCH (u:User {status: 'active'}) +MERGE (u)-[:HAS_SESSION]->(s:Session {id: randomUUID()}) + +// AFTER +CYPHER 25 +MATCH (u:User {status: 'active'}) +WITH collect(u) AS users +UNWIND users AS u +MERGE (u)-[:HAS_SESSION]->(s:Session {id: randomUUID()}) +``` + +**Fix 3: CALL IN TRANSACTIONS** — isolates each batch; each transaction is independent +```cypher +// BEFORE -- double Eager from two MERGEs on same label +CYPHER 25 +UNWIND $pairs AS pair +MERGE (a:Person {id: pair.a}) +MERGE (b:Person {id: pair.b}) +MERGE (a)-[:KNOWS]->(b) + +// AFTER +CYPHER 25 +UNWIND $pairs AS pair +CALL (pair) { + MERGE (a:Person {id: pair.a}) + MERGE (b:Person {id: pair.b}) + MERGE (a)-[:KNOWS]->(b) +} IN TRANSACTIONS OF 500 ROWS +``` + +--- + +## Label Inference [Neo4j 5 / 2025.x] + +When the planner underestimates selectivity on multi-label queries: + +```cypher +// Per-query hint +CYPHER inferSchemaParts = most_selective_label +MATCH (admin:Administrator {name: $adminName}), + (resource:Resource {name: $resourceName}) +MATCH p=(admin)-[:MEMBER_OF]->()-[:ALLOWED_INHERIT]->(company) + -[:WORKS_FOR|HAS_ACCOUNT]-()-[:WORKS_FOR|HAS_ACCOUNT]-(resource) +RETURN count(p) AS accessCount +``` + +Instance-wide config: `dbms.cypher.infer_schema_parts = MOST_SELECTIVE_LABEL` + +Impact: uses existing statistics + advanced deduction; can improve OLTP plans from ~13ms → ~80µs on complex multi-hop patterns. Verify with `EXPLAIN` — plan should show index seeks, not NodeByLabelScan. + +--- + +## Batching Best Practices [Neo4j 5 / 2025.x] + +Prefer native `CALL IN TRANSACTIONS` over `apoc.periodic.iterate` (APOC Core is maintenance-mode). + +```cypher +// Modern pattern — full planner visibility, accurate stats, memory tracking +CYPHER 25 +MATCH (n:Person) +CALL (n) { + SET n.score = toInteger(rand() * 20 + 1) +} IN TRANSACTIONS OF 1000 ROWS + ON ERROR CONTINUE + REPORT STATUS AS s +WITH s WHERE s.errorMessage IS NOT NULL +RETURN s.transactionId, s.errorMessage + +// Parallel batches [2025.01] +CYPHER 25 +LOAD CSV WITH HEADERS FROM 'file:///data.csv' AS row +CALL (row) { + MERGE (:Movie {id: row.id}) +} IN 4 CONCURRENT TRANSACTIONS OF 500 ROWS + ON ERROR RETRY FOR 30 SECS +``` + +`ON ERROR` options: `FAIL` (default) | `CONTINUE` | `BREAK` | `RETRY FOR N SECS` [2025.03+] + +Advantages over `apoc.periodic.iterate`: memory tracking prevents OOM, planner shows execution plan, accurate query statistics, no double entity ID fetch. diff --git a/.agents/skills/neo4j-cypher-skill/references/schema-guardrail.md b/.agents/skills/neo4j-cypher-skill/references/schema-guardrail.md new file mode 100644 index 0000000000..60810ee83b --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/schema-guardrail.md @@ -0,0 +1,163 @@ +# Schema Guardrail Reference + +## Schema File + +`-schema.json` — name after your database (e.g. `movies-schema.json`). Place anywhere in the project. + +### Generate from existing database (requires APOC) +```bash +pip install neo4j python-dotenv +python scripts/generate_schema.py +``` +`.env` (add to `.gitignore`): +``` +NEO4J_URI=neo4j+s://.databases.neo4j.io +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=your-password +NEO4J_DATABASE=neo4j +``` + +### Build interactively (no DB needed) +```bash +python scripts/define_schema.py +``` + +### Convert from existing JSON schema +```bash +python scripts/import_neo4j_schema.py path/to/input-schema.json +``` +Auto-detects: `neo4j-graphrag-python` SchemaBuilder, `graph-schema-introspector`, `graph-schema-json-js-utils`, `mcp-neo4j-data-modeling`. + +--- + +## Schema Format (APOC meta.schema) + +```json +{ + "schema_retrieved_at": "2026-06-06T10:00:00+00:00", + "value": { + "Theme": { + "type": "node", + "properties": { + "name": { "type": "STRING" }, + "theme_id": { "type": "INTEGER" } + }, + "relationships": { + "HAS_SET": { "direction": "out", "labels": ["Set"], "properties": {} } + } + }, + "Set": { + "type": "node", + "properties": { + "name": { "type": "STRING" }, + "id": { "type": "STRING" }, + "year": { "type": "INTEGER" }, + "pieces": { "type": "INTEGER" } + }, + "relationships": { + "HAS_SET": { "direction": "in", "labels": ["Theme"], "properties": {} }, + "HAS_MINIFIG": { "direction": "out", "labels": ["Minifig"], + "properties": { "quantity": { "type": "INTEGER" } } } + } + }, + "Minifig": { + "type": "node", + "properties": { + "name": { "type": "STRING" }, + "fig_num": { "type": "STRING" }, + "num_parts": { "type": "INTEGER" } + } + }, + "HAS_MINIFIG": { + "type": "relationship", + "properties": { "quantity": { "type": "INTEGER" } } + } + } +} +``` + +--- + +## Validation Rules + +Reason about intent before asking. Ask only when unable to resolve — never generate wrong Cypher silently, but don't stop when a safe interpretation exists. + +**1. Existence** — labels, rel-types, properties must be in schema. On miss: try synonym resolution → structural match → ask. + +**2. Synonym mapping** +- Unambiguous → resolve silently: `ℹ️ Resolved 'Minifigure' → 'Minifig'.` +- Ambiguous → pick most likely from context, note: `ℹ️ 'Fig' → 'Minifig' (context). Correct if wrong.` +- No match → surface candidates: `⚠️ 'Character' not found. Did you mean: Theme, Set, Minifig?` + +**3. Property type** — valid types: `STRING` `INTEGER` `FLOAT` `BOOLEAN` `DATE` `DATETIME` `LOCAL_DATETIME` `TIME` `LOCAL_TIME` `DURATION` `POINT` `LIST`. On mismatch: +- String against INTEGER (`'unknown'`, `'n/a'`) → rewrite as `IS NULL` and note +- Clearly wrong literal → propose correction and ask + +**4. Relationship direction** — `out` = `(a)-[:R]->(b)`, `in` = `(b)-[:R]->(a)`. Wrong direction → correct silently, note: +``` +HAS_SET | Schema: Theme──→Set | Prompt: Set──→Theme | ↩ Corrected +``` + +**5. Generate** — Cypher 25; use literals for interactive execution, `$param` for code generation; return only schema-declared properties. + +--- + +## Examples + +### Valid query +``` +User: "List minifigures in the Cloud City set" + +✅ Set | ✅ Minifig | ✅ HAS_MINIFIG (Set→Minifig) + +CYPHER 25 +MATCH (s:Set {id: $setId})-[:HAS_MINIFIG]->(m:Minifig) +RETURN m.name AS minifigName, m.fig_num AS figNum, m.num_parts AS numParts +ORDER BY m.name +// Parameters: { setId: "10123-1" } +``` + +### Entity not found +``` +User: "Find all Character nodes linked to a Movie" + +❌ Character NOT FOUND | ❌ Movie NOT FOUND +Schema nodes: Theme, Set, Minifig + +⚠️ Neither 'Character' nor 'Movie' exists in this schema. +Did you mean Set linked to Minifig, or are you querying a different database? +``` + +### Synonym resolved +``` +User: "Find all Minifigures in a set" + +ℹ️ Resolved 'Minifigure' → 'Minifig'. Proceeding. + +CYPHER 25 +MATCH (s:Set {id: $setId})-[:HAS_MINIFIG]->(m:Minifig) +RETURN m.name AS minifigName, m.fig_num AS figNum +// Parameters: { setId: $setId } +``` + +### Type mismatch +``` +User: "Find sets where pieces is 'unknown'" + +Set.pieces declared INTEGER, value 'unknown' is a STRING. +Interpreting as null/missing-value check. + +ℹ️ Rewritten: WHERE s.pieces IS NULL. Correct if you meant something else. + +CYPHER 25 +MATCH (s:Set) WHERE s.pieces IS NULL RETURN s.name, s.id +``` + +--- + +## Commit or ignore schema.json file? + +**Commit** when schema is stable and shared, or needed for CI without a live DB. +**Ignore** (`*-schema.json` → `.gitignore`) when schema contains sensitive names or evolves rapidly. + +`schema_retrieved_at` in the file records when the snapshot was taken. diff --git a/.agents/skills/neo4j-cypher-skill/references/syntax-traps.md b/.agents/skills/neo4j-cypher-skill/references/syntax-traps.md new file mode 100644 index 0000000000..cb0ba501f0 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/references/syntax-traps.md @@ -0,0 +1,46 @@ +# Common Syntax Traps + +Load this when debugging a syntax error or validating a query before returning it. + +| Invalid | Correct | +|---|---| +| `ORDER BY n.prop AS alias DESC` | `ORDER BY n.prop DESC` — `AS` not allowed in ORDER BY | +| `ORDER BY n.score DESC NULLS LAST` | `ORDER BY n.score DESC` — NULLS LAST is SQL, not Cypher | +| `ORDER BY preAggVar` after aggregating RETURN | Use the RETURN alias: `RETURN count(m) AS cnt ORDER BY cnt` | +| `count(r WHERE r.rating = 5)` | `sum(CASE WHEN r.rating = 5 THEN 1 ELSE 0 END)` | +| `collect(x ORDER BY y)` | Preceding `ORDER BY y` clause, or `COLLECT { MATCH ... RETURN x ORDER BY y }` | +| `rank() OVER (PARTITION BY ...)` | Not valid — use `collect({v:v}) AS ranked UNWIND range(0, size(ranked)-1) AS idx` | +| `UNWIND list AS x WHERE x > 5` | `UNWIND list AS x WITH x WHERE x > 5` | +| `FOREACH ... RETURN` | Use `UNWIND` when you need RETURN | +| `least(a,b)` / `greatest(a,b)` | `CASE WHEN a < b THEN a ELSE b END` | +| `-- SQL comment` | `// Cypher comment` | +| `FILTER x IN list WHERE ...` | `[x IN list WHERE ...]` — `FILTER` clause exists (Cypher 25 / 2025.06) but is not a list-comprehension form | +| `LET x = expr` | `LET` clause valid in Cypher 25 (Neo4j 2025.06+); on older versions use `WITH expr AS x` | +| `INSERT (p:Person {name:'A'})` | `INSERT` is a Cypher 25 synonym for `CREATE` (Neo4j 2025.06+) but multi-labels must use `&` not `:` and dynamic labels/types are not supported; on older versions use `CREATE (p:Person {name: 'A'})` | +| `shortestPath((a)-[*]->(b))` | `SHORTEST 1 (a)(()-[]->()){1,}(b)` | +| `allShortestPaths((a)-[*]->(b))` | `ALL SHORTEST (a)(()-[]->()){1,}(b)` | +| `id(n)` | `elementId(n)` | +| `[:REL*1..5]` | `(()-[:REL]->()){1,5}` | +| `CALL { WITH x ... }` | `CALL (x) { ... }` — importing WITH is deprecated | +| `apoc.coll.sort(list)` | `coll.sort(list)` — native Cypher 25 built-in | +| `n.dateProp >= date('2025-01-01')` on ZONED DATETIME | Use `.year` accessor or `datetime()` literal | +| `duration.between(d1,d2).inDays` | `duration.between(d1,d2).days` — `.inDays` does not exist | +| `WHERE n.x = null` | `WHERE n.x IS NULL` | +| `WHERE n.x <> null` | `WHERE n.x IS NOT NULL` | +| `MATCH (n:A) MATCH (m:A)` without join predicate | Causes CartesianProduct — add `WHERE` join condition | +| `COLLECT { (a)-[:R]->(b) }` | `COLLECT { MATCH (a)-[:R]->(b) RETURN b }` — bare pattern invalid | +| `COLLECT { MATCH ... RETURN x, y }` | `COLLECT {}` must return exactly one column | +| `min()` / `max()` as scalar in `range()` | Use `CASE WHEN size(l) < 3 THEN size(l)-1 ELSE 2 END` — these are aggregations | +| `(a)-[:REL]-{2,4}-(b)` bare quantifier | Wrap in node group: `(a)(()-[:REL]->()){2,4}(b)` | +| `MATCH REPEATABLE ELEMENTS ... {1,}` | `REPEATABLE ELEMENTS` requires bounded `{m,n}` | +| `2 IN [1, null, 3]` expecting `false` | Returns `null` — guard source list with IS NOT NULL | +| `SET n = r` (copy rel to node) | `SET n = properties(r)` — direct assignment transfers element reference | +| `n.$key` dynamic property | `n[$key]` — dot notation with parameter is SyntaxError | +| `MATCH (n) SET n:$label` (bare string) | `SET n:$($label)` — dynamic label requires `$()` wrapper | +| `DELETE n` on node with relationships | `DETACH DELETE n` — plain DELETE throws if node has relationships | +| `SET n = {key: val}` for partial update | `SET n += {key: val}` — `=` replaces ALL properties | +| `(a)-[:R]-(b)` expecting one direction | Returns matches in both directions — use `(a)-[:R]->(b)` | +| `RETURN DISTINCT a, b` deduplicates `a` | `RETURN DISTINCT` deduplicates complete rows, not individual columns | +| `CALL IN TRANSACTIONS` inside an explicit transaction | Requires auto-commit session | +| `PERIODIC COMMIT` in LOAD CSV | Deprecated — use `LOAD CSV ... CALL (...) { } IN TRANSACTIONS OF N ROWS` | +| `toInteger(null)` throws | `toIntegerOrNull(null)` returns `null` safely | diff --git a/.agents/skills/neo4j-cypher-skill/scripts/define_schema.py b/.agents/skills/neo4j-cypher-skill/scripts/define_schema.py new file mode 100644 index 0000000000..b83cec7ae0 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/scripts/define_schema.py @@ -0,0 +1,123 @@ +import json +from datetime import datetime, timezone + +SCALAR_TYPES = [ + "STRING", "INTEGER", "FLOAT", "BOOLEAN", + "DATE", "DATETIME", "LOCAL_DATETIME", "TIME", "LOCAL_TIME", "DURATION", + "POINT", +] +VALID_TYPES = SCALAR_TYPES + ["LIST"] + [f"LIST<{t}>" for t in SCALAR_TYPES] + + +def prompt_type(prop_name): + while True: + t = input(f" Type for '{prop_name}' {VALID_TYPES}: ").strip().upper() + if t in VALID_TYPES: + return t + print(f" Invalid type. Choose from: {VALID_TYPES}") + + +def define_properties(): + properties = {} + print(" Properties (leave name blank to finish):") + while True: + name = input(" Property name: ").strip() + if not name: + break + type_ = prompt_type(name) + properties[name] = { + "type": type_, + "indexed": False, + "unique": False, + "existence": False, + } + return properties + + +def main(): + print("\nNeo4j Schema Definition Tool") + print("Builds -schema.json by defining your graph schema before the database exists.") + print("=" * 60) + + schema = {"value": {}} + node_labels = [] + + print("\nStep 1: Define Node Labels") + while True: + label = input(" Node label (blank to finish): ").strip() + if not label: + break + print(f" Defining '{label}':") + props = define_properties() + schema["value"][label] = { + "type": "node", + "count": 0, + "properties": props, + "relationships": {}, + "labels": [], + } + node_labels.append(label) + print(f" '{label}' added.\n") + + if not node_labels: + print("No nodes defined. Exiting.") + return + + print(f"\nStep 2: Define Relationships") + print(f" Available nodes: {node_labels}") + rel_types = set() + + while True: + rel = input("\n Relationship type (blank to finish): ").strip().upper() + if not rel: + break + + from_label = input(f" From node: ").strip() + to_label = input(f" To node: ").strip() + + if from_label not in schema["value"]: + print(f" '{from_label}' not found. Skipping.") + continue + if to_label not in schema["value"]: + print(f" '{to_label}' not found. Skipping.") + continue + + print(f" Properties for [{rel}] (optional):") + props = define_properties() + + schema["value"][from_label]["relationships"][rel] = { + "direction": "out", + "labels": [to_label], + "count": 0, + "properties": {k: {**v, "array": False} for k, v in props.items()}, + } + + schema["value"][to_label]["relationships"][rel] = { + "direction": "in", + "labels": [from_label], + "count": 0, + "properties": {k: {**v, "array": False} for k, v in props.items()}, + } + + schema["value"][rel] = { + "type": "relationship", + "count": 0, + "properties": props, + } + + rel_types.add(rel) + print(f" ({from_label})-[:{rel}]->({to_label}) added.") + + db_name = input("\nDatabase name for schema file (e.g. 'movies', 'supply-chain'): ").strip() or "neo4j" + output_path = f"{db_name}-schema.json" + schema["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat() + with open(output_path, "w", encoding="utf-8") as f: + json.dump(schema, f, indent=2) + + print(f"\nSchema saved to {output_path}") + print(f" Nodes: {node_labels}") + print(f" Relationships: {sorted(rel_types)}") + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/neo4j-cypher-skill/scripts/generate_schema.py b/.agents/skills/neo4j-cypher-skill/scripts/generate_schema.py new file mode 100644 index 0000000000..48d1ed2ef0 --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/scripts/generate_schema.py @@ -0,0 +1,69 @@ +""" +Export APOC meta.schema from a live Neo4j instance. + +Usage: + python scripts/generate_schema.py [db-name] + +Reads credentials from environment variables or a .env file: + NEO4J_URI (default: bolt://localhost:7687) + NEO4J_USERNAME (default: neo4j) + NEO4J_PASSWORD (required) + NEO4J_DATABASE (default: db-name arg or "neo4j") + +Output: -schema.json in the current directory. +Add *-schema.json to .gitignore if the schema contains sensitive structure. +""" + +import os +import json +import sys +from datetime import datetime, timezone + +from neo4j import GraphDatabase + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass # python-dotenv optional; env vars already set take precedence + +URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") +USERNAME = os.getenv("NEO4J_USERNAME", "neo4j") +PASSWORD = os.getenv("NEO4J_PASSWORD") + + +def fetch_and_map_schema(db_name=None): + if not PASSWORD: + print("Error: NEO4J_PASSWORD is not set. Add it to your .env file or environment.") + sys.exit(1) + + name = db_name or os.getenv("NEO4J_DATABASE", "neo4j") + print(f"Connecting to {URI} (database: {name}) ...") + + try: + with GraphDatabase.driver(URI, auth=(USERNAME, PASSWORD)) as driver: + records, _, _ = driver.execute_query( + "CALL apoc.meta.schema()", database_=name + ) + + if not records: + print("No schema records returned. Is APOC installed?") + return + + raw_schema = records[0].data() + raw_schema["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat() + + output_path = f"{name}-schema.json" + with open(output_path, "w", encoding="utf-8") as f: + json.dump(raw_schema, f, indent=2) + + print(f"Schema saved to {output_path}") + + except Exception as e: + print(f"Failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + db_name = sys.argv[1] if len(sys.argv) > 1 else None + fetch_and_map_schema(db_name) diff --git a/.agents/skills/neo4j-cypher-skill/scripts/import_neo4j_schema.py b/.agents/skills/neo4j-cypher-skill/scripts/import_neo4j_schema.py new file mode 100644 index 0000000000..039ce9a34e --- /dev/null +++ b/.agents/skills/neo4j-cypher-skill/scripts/import_neo4j_schema.py @@ -0,0 +1,227 @@ +""" +Converts Neo4j schema JSON formats into an APOC meta.schema-compatible `*-schema.json` file. + +Supported formats (auto-detected): + - neo4j-graphrag-python SchemaBuilder JSON + - Neo4j standard graph schema JSON (graph-schema-introspector, graph-schema-json-js-utils, + mcp-neo4j-data-modeling) + +Usage: + python scripts/import_neo4j_schema.py +""" + +import json +import os +import sys +from datetime import datetime, timezone + + +def neo4j_type_to_apoc(type_def): + if not isinstance(type_def, dict): + return "STRING" + mapping = { + "string": "STRING", + "integer": "INTEGER", + "float": "FLOAT", + "boolean": "BOOLEAN", + "date": "DATE", + "datetime": "DATETIME", + "local_datetime": "LOCAL_DATETIME", + "time": "TIME", + "local_time": "LOCAL_TIME", + "duration": "DURATION", + "point": "POINT", + "array": "LIST", + "list": "LIST", + } + return mapping.get(type_def.get("type", "string").lower(), "STRING") + + +def convert_graphrag(schema): + """Convert neo4j-graphrag-python SchemaBuilder format to APOC format.""" + data = schema.get("schema", schema) + apoc = {"value": {}} + + # Parse node types — can be strings or dicts + node_labels = [] + for nt in data.get("node_types", []): + if isinstance(nt, str): + label = nt + properties = {"name": {"type": "STRING", "indexed": False, "unique": False, "existence": False}} + else: + label = nt.get("label", nt.get("name", "Unknown")) + properties = {} + for prop in nt.get("properties", []): + if isinstance(prop, str): + properties[prop] = {"type": "STRING", "indexed": False, "unique": False, "existence": False} + else: + properties[prop.get("name", prop.get("token", "prop"))] = { + "type": prop.get("type", "STRING").upper(), + "indexed": False, + "unique": False, + "existence": False, + } + if not properties: + properties["name"] = {"type": "STRING", "indexed": False, "unique": False, "existence": False} + + apoc["value"][label] = { + "type": "node", + "count": 0, + "properties": properties, + "relationships": {}, + "labels": [], + } + node_labels.append(label) + + # Parse relationship types — can be strings or dicts + rel_labels = [] + for rt in data.get("relationship_types", []): + label = rt if isinstance(rt, str) else rt.get("label", rt.get("name", "RELATED")) + rel_labels.append(label) + apoc["value"][label] = {"type": "relationship", "count": 0, "properties": {}} + + # Wire up directions from patterns: [source, rel, target] + for pattern in data.get("patterns", []): + if len(pattern) != 3: + continue + from_label, rel_token, to_label = pattern + + if from_label in apoc["value"]: + apoc["value"][from_label]["relationships"][rel_token] = { + "direction": "out", + "labels": [to_label], + "count": 0, + "properties": {}, + } + + if to_label in apoc["value"]: + apoc["value"][to_label]["relationships"][rel_token] = { + "direction": "in", + "labels": [from_label], + "count": 0, + "properties": {}, + } + + return apoc + + +def resolve_ref(ref, node_labels, node_obj_types): + key = ref.lstrip("#") + if key in node_labels: + return node_labels[key] + if key in node_obj_types: + obj = node_obj_types[key] + label_ref = obj.get("labels", [{}])[0].get("$ref", "").lstrip("#") + return node_labels.get(label_ref, key) + return key + + +def convert_standard(neo4j_schema): + """Convert Neo4j standard graph schema JSON format to APOC format.""" + graph = neo4j_schema.get("graphSchemaRepresentation", {}).get("graphSchema", {}) + + node_labels = {nl["$id"]: nl["token"] for nl in graph.get("nodeLabels", [])} + rel_types = {rt["$id"]: rt["token"] for rt in graph.get("relationshipTypes", [])} + node_obj_types = {n["$id"]: n for n in graph.get("nodeObjectTypes", [])} + rel_obj_types = graph.get("relationshipObjectTypes", []) + + apoc = {"value": {}} + + for nid, nobj in node_obj_types.items(): + label_ref = nobj.get("labels", [{}])[0].get("$ref", "").lstrip("#") + label = node_labels.get(label_ref, nid) + properties = {} + for prop in nobj.get("properties", []): + properties[prop["token"]] = { + "type": neo4j_type_to_apoc(prop.get("type", {})), + "indexed": False, + "unique": False, + "existence": not prop.get("nullable", True), + } + apoc["value"][label] = { + "type": "node", + "count": 0, + "properties": properties, + "relationships": {}, + "labels": [], + } + + for robj in rel_obj_types: + rel_token = rel_types.get(robj["type"]["$ref"].lstrip("#"), "UNKNOWN") + from_label = resolve_ref(robj["from"]["$ref"], node_labels, node_obj_types) + to_label = resolve_ref(robj["to"]["$ref"], node_labels, node_obj_types) + + rel_props = {} + for prop in robj.get("properties", []): + rel_props[prop["token"]] = { + "type": neo4j_type_to_apoc(prop.get("type", {})), + "indexed": False, + "unique": False, + "existence": not prop.get("nullable", True), + "array": False, + } + + if from_label in apoc["value"]: + apoc["value"][from_label]["relationships"][rel_token] = { + "direction": "out", "labels": [to_label], "count": 0, "properties": rel_props, + } + + if to_label in apoc["value"]: + apoc["value"][to_label]["relationships"][rel_token] = { + "direction": "in", "labels": [from_label], "count": 0, "properties": rel_props, + } + + apoc["value"][rel_token] = { + "type": "relationship", + "count": 0, + "properties": {k: {kk: vv for kk, vv in v.items() if kk != "array"} for k, v in rel_props.items()}, + } + + return apoc + + +def detect_and_convert(schema): + """Auto-detect schema format and convert to APOC.""" + # graphrag format: has 'schema' key with 'node_types' and 'patterns' + data = schema.get("schema", schema) + if "node_types" in data and "patterns" in data: + print("Detected: neo4j-graphrag-python SchemaBuilder format") + return convert_graphrag(schema) + + # Neo4j standard JSON format + if "graphSchemaRepresentation" in schema: + print("Detected: Neo4j standard graph schema JSON format") + return convert_standard(schema) + + raise ValueError( + "Unrecognised schema format. Supported: neo4j-graphrag-python SchemaBuilder, " + "Neo4j standard graph schema JSON (graphSchemaRepresentation)." + ) + + +def main(): + if len(sys.argv) < 2: + print("Usage: python scripts/import_neo4j_schema.py ") + print("Supported formats: neo4j-graphrag-python SchemaBuilder, Neo4j standard graph schema JSON") + sys.exit(1) + + input_path = sys.argv[1] + with open(input_path, "r", encoding="utf-8") as f: + schema = json.load(f) + + apoc = detect_and_convert(schema) + apoc["schema_retrieved_at"] = datetime.now(timezone.utc).isoformat() + + base = os.path.splitext(os.path.basename(input_path))[0] + output_path = f"{base}.json" if base.endswith("-schema") else f"{base}-schema.json" + with open(output_path, "w", encoding="utf-8") as f: + json.dump(apoc, f, indent=2) + + node_count = sum(1 for v in apoc["value"].values() if v.get("type") == "node") + rel_count = sum(1 for v in apoc["value"].values() if v.get("type") == "relationship") + print(f"✅ Converted: {node_count} node types, {rel_count} relationship types") + print(f" Saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/neo4j-driver-python-skill/README.md b/.agents/skills/neo4j-driver-python-skill/README.md new file mode 100644 index 0000000000..538bc6f5ad --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/README.md @@ -0,0 +1,31 @@ +# neo4j-driver-python-skill + +Skill for writing Python applications that connect to Neo4j using the official Neo4j Python Driver. + +**Covers:** +- Installation and driver lifecycle (singleton pattern, `verify_connectivity`) +- URI schemes and auth options (Aura, bolt, bearer, Kerberos) +- `execute_query` — default API with `RoutingControl`, `result_transformer_`, trailing-underscore convention +- Managed transactions (`execute_read` / `execute_write`) — retry safety, result lifetime, `@unit_of_work` +- Implicit transactions (`session.run`) — `LOAD CSV`, `CALL {} IN TRANSACTIONS` +- Async driver (`AsyncGraphDatabase`) — FastAPI lifespan pattern, `asyncio.gather` +- Error handling — `ConstraintError`, `ServiceUnavailable`, `TransientError`, GQL status codes +- Result access — `Record`, `record.data()`, JSON serialization gotchas +- Data type mapping — Python ↔ Cypher, temporal types, graph objects (`Node`, `Relationship`) +- UNWIND batch writes (`list[dict]` only) +- Connection pool tuning and session exhaustion +- Causal consistency and bookmarks + +**Version / compatibility:** +- Driver v6.x (Jan 2026+) — package name is `neo4j`, not `neo4j-driver` +- Python ≥ 3.10 required + +**Not covered:** +- Cypher query authoring → use `neo4j-cypher-skill` +- Driver version upgrades / breaking changes → use `neo4j-migration-skill` +- GraphRAG pipelines (`neo4j-graphrag` package) → use `neo4j-graphrag-skill` + +**Install:** +```bash +pip install neo4j +``` diff --git a/.agents/skills/neo4j-driver-python-skill/SKILL.md b/.agents/skills/neo4j-driver-python-skill/SKILL.md new file mode 100644 index 0000000000..732fa3af60 --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/SKILL.md @@ -0,0 +1,429 @@ +--- +name: neo4j-driver-python-skill +description: Neo4j Python Driver v6 — driver lifecycle, execute_query, managed and explicit + transactions, async (AsyncGraphDatabase), result handling, data type mapping, error handling, + UNWIND batching, connection pool tuning, and causal consistency. Use when writing Python + code that connects to Neo4j via GraphDatabase.driver, execute_query, execute_read, + execute_write, AsyncGraphDatabase, neo4j.Result, or RoutingControl. Package name is + `neo4j` (not neo4j-driver) since v6. Python >=3.10 required. + Does NOT handle Cypher query authoring — use neo4j-cypher-skill. + Does NOT cover driver upgrades or breaking changes — use neo4j-migration-skill. + Does NOT cover GraphRAG pipelines (neo4j-graphrag package) — use neo4j-graphrag-skill. +version: 1.0.1 +allowed-tools: Bash WebFetch +--- + +## When to Use +- Writing Python code that connects to Neo4j +- Setting up driver, sessions, transactions, or async patterns +- Debugging result handling, serialization, or UNWIND batching +- Reviewing Neo4j driver usage in Python code + +## When NOT to Use +- **Writing/optimizing Cypher** → `neo4j-cypher-skill` +- **Driver version upgrades** → `neo4j-migration-skill` +- **GraphRAG pipelines** (`neo4j-graphrag` package) → `neo4j-graphrag-skill` + +--- + +## Installation + +```bash +pip install neo4j # package name is `neo4j`, NOT `neo4j-driver` (deprecated since v6) +pip install neo4j-rust-ext # optional: 3–10× faster serialization, same API +``` + +**Python >=3.10 required** for v6.x. Python 3.14 supported [6.1+]. Pandas 3 and PyArrow 23/24 supported [6.2+]. + +--- + +## Environment Variables + +Load connection config from environment — never hardcode credentials. + +```python +import os +from dotenv import load_dotenv # pip install python-dotenv + +load_dotenv(".env") # reads NEO4J_URI / NEO4J_USERNAME / NEO4J_PASSWORD / NEO4J_DATABASE + +URI = os.getenv("NEO4J_URI", "neo4j://localhost:7687") +USER = os.getenv("NEO4J_USERNAME", "neo4j") +PASSWORD = os.getenv("NEO4J_PASSWORD", "") +DATABASE = os.getenv("NEO4J_DATABASE", "neo4j") +``` + +`.env` file format: +``` +NEO4J_URI=neo4j+s://xxx.databases.neo4j.io +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=secret +NEO4J_DATABASE=neo4j +``` + +Add `.env` to `.gitignore`. Without `python-dotenv`, use `export` in shell or `os.getenv` directly. + +--- + +## Driver Lifecycle + +Create **one Driver per application**. Thread-safe, expensive to create. Never create per-request. + +```python +from neo4j import GraphDatabase + +URI = "neo4j+s://xxx.databases.neo4j.io" # Aura +AUTH = ("neo4j", "password") + +# Context manager — preferred for scripts +with GraphDatabase.driver(URI, auth=AUTH) as driver: + driver.verify_connectivity() + # ... work ... + +# Long-lived singleton (service / web app) +driver = GraphDatabase.driver(URI, auth=AUTH) +driver.verify_connectivity() +# on shutdown: +driver.close() +``` + +URI schemes: + +| Scheme | Use | +|---|---| +| `neo4j+s://` | TLS + cluster routing — **Aura default** | +| `neo4j://` | Unencrypted + cluster routing | +| `bolt+s://` | TLS, single instance | +| `bolt://` | Unencrypted, single instance | + +Auth options: `("user", "pass")` tuple, `basic_auth()`, `bearer_auth("jwt")`, `kerberos_auth("b64")`. + +--- + +## Choosing the Right API + +| API | Use when | Auto-retry | Streaming | +|---|---|---|---| +| `driver.execute_query()` | Most queries — simple, safe default | ✅ | ❌ eager | +| `session.execute_read/write()` | Large results / multiple queries in one tx | ✅ | ✅ | +| `session.run()` | `LOAD CSV`, `CALL {} IN TRANSACTIONS`, scripts | ⚠️ one-shot [6.2+] | ✅ | +| `AsyncGraphDatabase` | asyncio applications | ✅ | ✅ | + +`session.run()` retry [6.2+]: single immediate retry on DBMS-marked idempotent errors only (currently admission control). Disable with `disable_auto_commit_retries=True` at driver or session level. + +--- + +## `execute_query` — Default API + +```python +from neo4j import GraphDatabase, RoutingControl + +# Tuple unpacking — most common +records, summary, keys = driver.execute_query( + "MATCH (p:Person {name: $name})-[:KNOWS]->(f) RETURN f.name AS name", + name="Alice", + routing_=RoutingControl.READ, # route reads to replicas + database_="neo4j", # always specify — saves a round-trip +) +for record in records: + print(record["name"]) +print(summary.result_available_after, "ms") + +# Write — check counters +summary = driver.execute_query( + "CREATE (p:Person {name: $name, age: $age})", + name="Bob", age=30, + database_="neo4j", +).summary +print(summary.counters.nodes_created) +``` + +**Trailing-underscore convention** — config kwargs end with `_` (`database_`, `routing_`, `auth_`, `result_transformer_`, `bookmark_manager_`). No query parameter name may end with `_`; pass those via `parameters_={"key_": val}`. + +**Never f-string or format Cypher.** Always `$param` — prevents injection and enables plan caching. + +`result_transformer_` — reshape before return: +```python +import neo4j +df = driver.execute_query("MATCH (p:Person) RETURN p.name, p.age", database_="neo4j", + result_transformer_=neo4j.Result.to_df) +record = driver.execute_query("MATCH (p:Person {name:$n}) RETURN p", n="Alice", database_="neo4j", + result_transformer_=neo4j.Result.single) # raises if 0 or 2+ results +``` + +`Result.single()` raises `ResultNotSingleError` on **zero** results (not just 2+). Use `single(strict=False)` for None-on-empty. + +--- + +## Managed Transactions (`execute_read` / `execute_write`) + +Use for large results or multiple queries in one transaction. + +```python +with driver.session(database="neo4j") as session: + + def get_people(tx): + result = tx.run("MATCH (p:Person) WHERE p.name STARTS WITH $pfx RETURN p.name AS name", + pfx="Al") + return [r["name"] for r in result] # consume INSIDE callback — Result invalid after tx closes + + names = session.execute_read(get_people) + + def create_person(tx): + tx.run("CREATE (p:Person {name: $name})", name="Carol") + + session.execute_write(create_person) +``` + +**Result lifetime** — `Result` is a lazy cursor backed by the open transaction. Returning it unconsumed raises `ResultConsumedError`. Always collect to `list` inside the callback. + +**Callback may retry** on transient failures — keep callbacks idempotent; move side effects (HTTP calls, emails) outside the callback. + +Timeout/metadata via `@unit_of_work` (named functions only — cannot decorate lambdas): +```python +from neo4j import unit_of_work + +@unit_of_work(timeout=5.0, metadata={"app": "svc", "user": user_id}) +def get_people(tx): + return [r["name"] for r in tx.run("MATCH (p:Person) RETURN p.name AS name")] + +session.execute_read(get_people) +``` + +--- + +## Implicit Transactions (`session.run`) + +Use only for `LOAD CSV`, `CALL {} IN TRANSACTIONS`, or quick scripts. `session.run()` does a single immediate retry on idempotent (DBMS-marked) errors only [6.2+]; other errors do not retry. + +```python +with driver.session(database="neo4j") as session: + result = session.run("CREATE (p:Person {name: $name})", name="Alice") + summary = result.consume() # call consume() to guarantee commit before proceeding + print(summary.counters.nodes_created) + +# Opt out of one-shot retry [6.2+] — driver- or session-level +driver = GraphDatabase.driver(URI, auth=AUTH, disable_auto_commit_retries=True) +with driver.session(database="neo4j", disable_auto_commit_retries=True) as session: + session.run("...") +``` + +--- + +## Async API + +Mirror of sync API — replace `GraphDatabase` with `AsyncGraphDatabase`, `await` every call. + +```python +from neo4j import AsyncGraphDatabase +import asyncio + +# Singleton — same rule as sync: never create per-request +driver = AsyncGraphDatabase.driver(URI, auth=AUTH) + +async def main(): + records, _, _ = await driver.execute_query( + "MATCH (p:Person) RETURN p.name AS name", + database_="neo4j", routing_=RoutingControl.READ, + ) + print([r["name"] for r in records]) + await driver.close() + +asyncio.run(main()) +``` + +FastAPI lifespan pattern: +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI + +_driver = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _driver + _driver = AsyncGraphDatabase.driver(URI, auth=AUTH) + await _driver.verify_connectivity() + yield + await _driver.close() + +app = FastAPI(lifespan=lifespan) +``` + +Parallel queries with `asyncio.gather`: +```python +results = await asyncio.gather( + driver.execute_query("MATCH (a:Artist) RETURN a.name AS name", database_="neo4j"), + driver.execute_query("MATCH (v:Venue) RETURN v.name AS name", database_="neo4j"), +) +``` + +**Never use sync `GraphDatabase` in asyncio** — blocks the event loop. + +Full async patterns → [references/async.md](references/async.md) + +--- + +## Error Handling + +```python +from neo4j.exceptions import ( + Neo4jError, ServiceUnavailable, TransientError, + AuthError, ConstraintError, +) + +try: + driver.execute_query("...", database_="neo4j") +except AuthError: + ... # bad credentials +except ServiceUnavailable: + ... # no servers reachable +except ConstraintError as e: + # unique/existence constraint violation — catch BEFORE Neo4jError (it's a subclass) + print(e.code, e.message) +except TransientError as e: + # raised only after retries exhausted (execute_query retries automatically) + print(e.code) +except Neo4jError as e: + print(e.code, e.message, e.gql_status) +``` + +Catch `ConstraintError` before `Neo4jError` — it is a subclass and will be swallowed otherwise. + +--- + +## Result Access & Null Safety + +```python +record = records[0] +record["name"] # by key — KeyError if absent +record[0] # by index +record.get("name") # None for absent key OR graph null +record.get("name", "Unknown") +d = record.data() # dict — values still driver objects for Node/Rel/temporal types +``` + +`record.data()` is **not JSON-safe** if result contains `Node`, `Relationship`, `Path`, or `neo4j.time.*` values. Project scalar fields in Cypher instead of returning whole nodes. + +```python +# ❌ raises TypeError on json.dumps +records, _, _ = driver.execute_query("MATCH (p:Person) RETURN p", database_="neo4j") +json.dumps(records[0].data()) + +# ✅ project scalars +records, _, _ = driver.execute_query( + "MATCH (p:Person) RETURN p.name AS name, p.age AS age", database_="neo4j") +json.dumps(records[0].data()) # safe +``` + +Node/Relationship/temporal access: +```python +node = record["p"] # neo4j.graph.Node +node.element_id # stable within this transaction only +node.labels # frozenset({'Person'}) +dict(node) # all properties as plain dict + +rel = record["r"] # neo4j.graph.Relationship +rel.type # 'KNOWS' + +dt = record["created_at"] # neo4j.time.DateTime +dt.to_native() # datetime.datetime (loses sub-µs precision) +``` + +Full type mapping table → [references/data-types.md](references/data-types.md) + +--- + +## Batch Writes with UNWIND + +Pass `list[dict]` — only shape the driver serializes correctly for `UNWIND`. + +```python +people = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] +driver.execute_query( + "UNWIND $rows AS row MERGE (p:Person {name: row.name}) SET p.age = row.age", + rows=people, + database_="neo4j", +) +``` + +Custom objects and dataclasses must be converted to `dict` before passing as parameters. + +--- + +## Performance + +- Always set `database_` / `database=` — omitting triggers a home-database round-trip per call. +- `execute_read` routes to replicas automatically; use `routing_=RoutingControl.READ` with `execute_query`. +- Batch writes: one `execute_write` callback for the whole list > one tx per item. +- Large results: stream lazily inside `execute_read` callback; `execute_query` is always eager. + +Connection pool tuning: +```python +driver = GraphDatabase.driver(URI, auth=AUTH, + max_connection_pool_size=50, # default 100 + connection_acquisition_timeout=30, # seconds to wait for free connection + max_connection_lifetime=3600, # seconds; recycles stale connections + connection_timeout=15, + keep_alive=True, +) +``` + +Session exhaustion: each open session holds a connection. Always use `with driver.session(...) as session`. + +Full performance patterns → [references/performance.md](references/performance.md) + +--- + +## Common Errors + +| Mistake | Fix | +|---|---| +| f-string / `.format()` Cypher params | Use `$param` placeholders always | +| Param name ending with `_` | Pass via `parameters_={"key_": val}` | +| Omitting `database_` | Always set — saves a round-trip every call | +| Returning `Result` from tx callback | Consume to `list` inside callback | +| Side effects in `execute_read/write` callback | Move outside — callback may retry | +| Passing dataclass/Pydantic as param | Convert to `dict` first | +| `UNWIND` with list of objects | `list[dict]` only | +| `record.get()` for absent-key detection | `"key" in record.keys()` for absent; `.get()` returns `None` for both absent and graph null | +| No `.consume()` after `session.run()` | Commit timing undefined; call `.consume()` | +| Sync driver inside asyncio | Use `AsyncGraphDatabase` — sync blocks event loop | +| Async driver created per request | Singleton — create once at startup | +| Leaked sessions | `with driver.session(...) as session` always | +| `json.dumps(record.data())` with node/temporal | Project scalars in Cypher or convert explicitly | +| `result["name"]` on `EagerResult` | Index `result.records[0]["name"]` or unpack `records, _, _ = ...` | +| `Result.single()` returns None for 0 results | It raises — use `single(strict=False)` | +| `@unit_of_work` on lambda | Use named function | +| `Neo4jError` caught before `ConstraintError` | Catch `ConstraintError` first — it's a subclass | +| `neo4j-driver` package name | Package is `neo4j` since v6; `neo4j-driver` deprecated | + +--- + +## References + +Load on demand: +- [references/async.md](references/async.md) — full async patterns: managed transactions, result methods, concurrency +- [references/data-types.md](references/data-types.md) — complete Python↔Cypher type mapping, temporal conversion, graph object API, spatial types (CartesianPoint/WGS84Point) +- [references/performance.md](references/performance.md) — connection pool, lazy streaming, threading vs asyncio, bookmarks/causal consistency +- [references/transactions.md](references/transactions.md) — explicit transactions, rollback, commit uncertainty, `unit_of_work` details + +Docs: +- https://neo4j.com/docs/python-manual/current/ +- https://neo4j.com/docs/api/python-driver/current/ + +--- + +## Checklist +- [ ] Package installed as `neo4j` (not `neo4j-driver`) +- [ ] One Driver instance created at startup; shared everywhere +- [ ] `verify_connectivity()` called at startup +- [ ] `database_` / `database=` set on every call +- [ ] `$param` placeholders used — no f-strings or `.format()` +- [ ] Result consumed inside tx callback (not returned raw) +- [ ] Sessions used as context managers (`with driver.session(...) as session`) +- [ ] `ConstraintError` caught before `Neo4jError` +- [ ] `AsyncGraphDatabase` used in asyncio code (not sync driver) +- [ ] Async driver created once at app startup (not per request) +- [ ] Side effects outside `execute_read/write` callbacks +- [ ] UNWIND batches use `list[dict]` diff --git a/.agents/skills/neo4j-driver-python-skill/references/async.md b/.agents/skills/neo4j-driver-python-skill/references/async.md new file mode 100644 index 0000000000..d0d9d4bb8f --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/references/async.md @@ -0,0 +1,114 @@ +# Async Driver — Full Reference + +## Setup + +```python +from neo4j import AsyncGraphDatabase, RoutingControl +import asyncio + +URI = "neo4j+s://xxx.databases.neo4j.io" +AUTH = ("neo4j", "password") + +# Singleton — never create per-request +driver = AsyncGraphDatabase.driver(URI, auth=AUTH) +await driver.verify_connectivity() +# on shutdown: +await driver.close() +``` + +## Async Managed Transactions + +```python +async def get_people(tx): + result = await tx.run("MATCH (p:Person) RETURN p.name AS name") + return await result.values() # consume INSIDE callback + +async def create_person(tx, name: str): + await tx.run("MERGE (p:Person {name: $name})", name=name) + +async def run_queries(driver): + async with driver.session(database="neo4j") as session: + people = await session.execute_read(get_people) + await session.execute_write(create_person, "Carol") +``` + +## Async Result Methods + +| Method | Returns | Notes | +|---|---|---| +| `await result.values()` | `list[list]` | One inner list per row | +| `await result.data()` | `list[dict]` | One dict per record, keyed by column name | +| `await result.single()` | `Record` | Raises if 0 or 2+ results | +| `await result.single(strict=False)` | `Record \| None` | None for 0, raises for 2+ | +| `await result.fetch(n)` | `list[Record]` | Up to n records | +| `await result.consume()` | `ResultSummary` | Discards remaining | +| `async for record in result` | iterates `Record` | Lazy streaming | + +## FastAPI Lifespan Pattern + +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI, Depends +from neo4j import AsyncGraphDatabase, RoutingControl + +_driver = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global _driver + _driver = AsyncGraphDatabase.driver(URI, auth=AUTH) + await _driver.verify_connectivity() + yield + await _driver.close() + +app = FastAPI(lifespan=lifespan) + +def get_driver(): + return _driver + +@app.get("/people") +async def get_people(driver=Depends(get_driver)): + records, _, _ = await driver.execute_query( + "MATCH (p:Person) RETURN p.name AS name", + database_="neo4j", + routing_=RoutingControl.READ, + ) + return [r["name"] for r in records] +``` + +## Concurrency with asyncio.gather + +```python +async def run_concurrent(driver): + results = await asyncio.gather( + driver.execute_query("MATCH (a:Artist) RETURN a.name AS name", database_="neo4j"), + driver.execute_query("MATCH (v:Venue) RETURN v.name AS name", database_="neo4j"), + ) + artists = [r["name"] for r in results[0].records] + venues = [r["name"] for r in results[1].records] +``` + +## Common Async Mistakes + +```python +# ❌ Sync driver in asyncio — blocks event loop +async def bad(): + with GraphDatabase.driver(URI, auth=AUTH) as driver: + records, _, _ = driver.execute_query("MATCH (p:Person) RETURN p") + +# ✅ Async driver +async def good(): + async with AsyncGraphDatabase.driver(URI, auth=AUTH) as driver: + records, _, _ = await driver.execute_query("MATCH (p:Person) RETURN p") + +# ❌ Async driver created per request — rebuilds connection pool every time +async def handle_request(name: str): + async with AsyncGraphDatabase.driver(URI, auth=AUTH) as driver: + records, _, _ = await driver.execute_query("...", database_="neo4j") + +# ✅ Singleton at startup +_driver = AsyncGraphDatabase.driver(URI, auth=AUTH) + +async def handle_request(name: str): + records, _, _ = await _driver.execute_query("...", database_="neo4j") +``` diff --git a/.agents/skills/neo4j-driver-python-skill/references/data-types.md b/.agents/skills/neo4j-driver-python-skill/references/data-types.md new file mode 100644 index 0000000000..b2fd3cdc47 --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/references/data-types.md @@ -0,0 +1,145 @@ +# Data Types — Python ↔ Cypher Mapping + +## Parameter Types (allowed) + +| Python type | Cypher type | +|---|---| +| `str` | String | +| `int` | Integer | +| `float` | Float | +| `bool` | Boolean | +| `list` / `tuple` | List | +| `dict` | Map | +| `None` | null | +| `datetime.date` | Date | +| `datetime.datetime` | DateTime | +| `datetime.time` | Time | +| `datetime.timedelta` | Duration | +| `neo4j.time.*` types | Corresponding Cypher temporal | + +Custom classes, dataclasses, Pydantic models, and enums are **not** auto-serialized — convert to `dict` or primitives first. + +```python +from dataclasses import dataclass, asdict + +@dataclass +class Person: + name: str + age: int + +p = Person("Alice", 30) + +# ❌ Fails +driver.execute_query("CREATE (p:Person $props)", props=p, database_="neo4j") + +# ✅ Convert to dict +driver.execute_query("CREATE (p:Person $props)", props=asdict(p), database_="neo4j") +``` + +## Graph Object API + +```python +# Node — neo4j.graph.Node +node = record["p"] +node.element_id # stable within this transaction; don't use across transactions +node.labels # frozenset({'Person'}) +node["name"] # property access by key +dict(node) # all properties as plain dict + +# Relationship — neo4j.graph.Relationship +rel = record["r"] +rel.type # 'KNOWS' +rel.start_node.element_id +rel.end_node.element_id +rel["since"] # property +dict(rel) # all properties as plain dict +``` + +## Temporal Types + +```python +from neo4j.time import DateTime, Date, Time, Duration + +dt = record["created_at"] # neo4j.time.DateTime +dt.to_native() # datetime.datetime — loses sub-microsecond precision +str(dt) # ISO 8601 string — JSON-safe + +# Pass Python datetime as a parameter — driver converts automatically +from datetime import datetime, timezone +driver.execute_query("CREATE (e:Event {at: $ts})", ts=datetime.now(timezone.utc), database_="neo4j") + +# Duration — access .days / .months (not .inDays / .inMonths) +dur = record["tenure"] # neo4j.time.Duration +dur.days +dur.months +``` + +## JSON Serialization + +`record.data()` returns a `dict` but `Node`, `Relationship`, `Path`, and `neo4j.time.*` values are still driver objects — not JSON-safe. + +```python +# ❌ Raises TypeError if result contains node/rel/temporal +json.dumps(records[0].data()) + +# ✅ Project scalars in Cypher +records, _, _ = driver.execute_query( + "MATCH (p:Person) RETURN p.name AS name, p.age AS age, toString(p.created_at) AS created_at", + database_="neo4j", +) +json.dumps(records[0].data()) # safe — all scalars + +# ✅ Extract node properties manually +node = records[0]["p"] +props = dict(node) # plain dict — json-safe if all property types are primitives +``` + +## Spatial Types + +```python +from neo4j.spatial import CartesianPoint, WGS84Point + +# 2D Cartesian (SRID 7203) +pt2d = CartesianPoint((1.23, 4.56)) +print(pt2d.x, pt2d.y, pt2d.srid) # 1.23, 4.56, 7203 + +# 3D Cartesian (SRID 9157) +pt3d = CartesianPoint((1.23, 4.56, 7.89)) +x, y, z = pt3d # destructuring + +# 2D WGS-84 (SRID 4326) +ldn = WGS84Point((-0.118092, 51.509865)) +print(ldn.longitude, ldn.latitude, ldn.srid) # -0.118092, 51.509865, 4326 + +# 3D WGS-84 (SRID 4979) +shard = WGS84Point((-0.086500, 51.504501, 310)) +longitude, latitude, height = shard + +# Distance (same SRID only — returns None if SRIDs differ) +records, _, _ = driver.execute_query( + "RETURN point.distance($p1, $p2) AS distance", + p1=CartesianPoint((1, 1)), p2=CartesianPoint((10, 10)), + database_="neo4j", +) +distance = records[0]["distance"] # float64 +``` + +Pass points as parameters — serialized automatically. Read back via destructuring or `.x`/`.y`/`.z`. + +## Null Safety + +| Situation | `record["key"]` | `record.get("key")` | +|---|---|---| +| Key present, value non-null | value | value | +| Key present, value is graph null | `None` | `None` | +| Key absent (typo / not in RETURN) | `KeyError` | `None` | + +`.get()` cannot distinguish absent key from graph null — use `"key" in record.keys()` when the distinction matters. + +```python +# Optional column from OPTIONAL MATCH +if "city" in record.keys() and record["city"] is not None: + city = record["city"] +else: + city = "Unknown" +``` diff --git a/.agents/skills/neo4j-driver-python-skill/references/performance.md b/.agents/skills/neo4j-driver-python-skill/references/performance.md new file mode 100644 index 0000000000..5ea3f1c2e4 --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/references/performance.md @@ -0,0 +1,123 @@ +# Performance & Scalability + +## Connection Pool Configuration + +```python +driver = GraphDatabase.driver(URI, auth=AUTH, + max_connection_pool_size=50, # default 100; tune to workload + connection_acquisition_timeout=30, # seconds to wait for free connection + max_connection_lifetime=3600, # seconds; recycles stale connections + connection_timeout=15, # seconds to establish new connection + keep_alive=True, # TCP keepalive +) +``` + +Each open session holds a connection. Leaked sessions exhaust the pool — new sessions block until `connection_acquisition_timeout` then raise `ClientError`. Always use `with driver.session(...) as session`. + +## Batch Writes — Three Patterns + +### UNWIND (best for bulk import) + +```python +rows = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] +driver.execute_query( + "UNWIND $rows AS row MERGE (p:Person {name: row.name}) SET p.age = row.age", + rows=rows, database_="neo4j", +) +``` + +### Group in one managed transaction + +```python +# ❌ One tx per item — high overhead +for item in items: + driver.execute_query("CREATE (n:Node {id: $id})", id=item["id"], database_="neo4j") + +# ✅ One callback for the whole batch +def bulk_create(tx): + for item in items: + tx.run("CREATE (n:Node {id: $id})", id=item["id"]) + +with driver.session(database="neo4j") as session: + session.execute_write(bulk_create) +``` + +### CALL IN TRANSACTIONS (very large data — use via session.run, not execute_query) + +```cypher +UNWIND $rows AS row +CALL (row) { + MERGE (p:Person {name: row.name}) +} IN TRANSACTIONS OF 1000 ROWS ON ERROR CONTINUE +``` + +## Lazy vs Eager Loading + +```python +# execute_query is always eager — fine for small/medium results +records, _, _ = driver.execute_query("MATCH (p:Person) RETURN p", database_="neo4j") + +# Large results — stream lazily inside managed transaction +def process_large_result(tx): + result = tx.run("MATCH (p:Person) RETURN p.name AS name") + for record in result: # one record at a time + process(record["name"]) # don't build a list + +with driver.session(database="neo4j") as session: + session.execute_read(process_large_result) +``` + +## Threading vs asyncio + +The Python GIL limits CPU parallelism for threads; both threads and asyncio overlap on I/O. + +```python +# Sync threading — OK for moderate I/O concurrency +from concurrent.futures import ThreadPoolExecutor + +def query(name): + records, _, _ = driver.execute_query( + "MATCH (p:Person {name: $name}) RETURN p", name=name, database_="neo4j" + ) + return records + +with ThreadPoolExecutor(max_workers=10) as pool: + results = list(pool.map(query, names)) + +# asyncio — preferred for high-concurrency workloads +async def run_all(names): + tasks = [ + driver.execute_query("MATCH (p:Person {name: $name}) RETURN p", + name=name, database_="neo4j") + for name in names + ] + return await asyncio.gather(*tasks) +``` + +## Causal Consistency & Bookmarks + +Within a single session, queries are automatically causally chained. Across sessions — use `execute_query` (shares `BookmarkManager` automatically), or pass bookmarks explicitly: + +```python +from neo4j import Bookmarks + +with driver.session(database="neo4j") as session_a: + session_a.execute_write(lambda tx: tx.run("MERGE (p:Person {name: 'Alice'})")) + bookmarks_a = session_a.last_bookmarks() + +with driver.session(database="neo4j") as session_b: + session_b.execute_write(lambda tx: tx.run("MERGE (p:Person {name: 'Bob'})")) + bookmarks_b = session_b.last_bookmarks() + +combined = Bookmarks.from_raw_values( + *bookmarks_a.raw_values, *bookmarks_b.raw_values +) + +with driver.session(database="neo4j", bookmarks=combined) as session_c: + session_c.execute_write( + lambda tx: tx.run("MATCH (a:Person {name:'Alice'}), (b:Person {name:'Bob'}) " + "MERGE (a)-[:KNOWS]->(b)") + ) +``` + +`execute_query` shares a `BookmarkManager` automatically — usually sufficient. diff --git a/.agents/skills/neo4j-driver-python-skill/references/transactions.md b/.agents/skills/neo4j-driver-python-skill/references/transactions.md new file mode 100644 index 0000000000..b351133c82 --- /dev/null +++ b/.agents/skills/neo4j-driver-python-skill/references/transactions.md @@ -0,0 +1,158 @@ +# Transactions — Full Reference + +## Explicit Transactions + +Use when a transaction spans multiple functions or coordinates with external state. + +```python +with driver.session(database="neo4j") as session: + tx = session.begin_transaction() + try: + do_part_a(tx) + do_part_b(tx) + tx.commit() + except Exception as e: + tx.rollback() + raise + +def do_part_a(tx): + tx.run("CREATE (p:Person {name: $name})", name="Alice") +``` + +### Rollback Can Raise + +`tx.rollback()` is a network call — if the connection is broken, it raises. Don't let it swallow the original exception: + +```python +try: + tx.commit() +except Exception as original: + try: + tx.rollback() + except Exception as rollback_err: + original.__suppress_context__ = False + raise rollback_err from original # chain both exceptions + raise +``` + +### Commit Uncertainty + +If `tx.commit()` raises a network-level exception, the commit may or may not have succeeded. Design writes as idempotent with `MERGE` and unique constraints so retrying is safe. + +## `@unit_of_work` — Timeout & Metadata + +Attaches timeout and server metadata to a managed transaction callback. + +```python +from neo4j import unit_of_work + +@unit_of_work(timeout=5.0, metadata={"app": "myService", "user": user_id}) +def get_people(tx): + return [r["name"] for r in tx.run("MATCH (p:Person) RETURN p.name AS name")] + +session.execute_read(get_people) +``` + +Metadata appears in `SHOW TRANSACTIONS` and server query logs. + +### Cannot Decorate Lambdas + +```python +# ❌ Syntax error — cannot decorate a lambda inline +session.execute_write( + @unit_of_work(timeout=5.0) + lambda tx: tx.run("MERGE (p:Person {name: $name})", name="Alice") +) + +# ❌ Also wrong — the original lambda is used, not the wrapped version +fn = lambda tx: tx.run("MERGE (p:Person {name: $name})", name="Alice") +unit_of_work(timeout=5.0)(fn) # wraps fn, but not reassigned +session.execute_write(fn) # uses original + +# ✅ Named function with decorator +@unit_of_work(timeout=5.0, metadata={"app": "myService"}) +def create_person(tx): + tx.run("MERGE (p:Person {name: $name})", name="Alice") + +session.execute_write(create_person) + +# ✅ Assign the wrapped lambda explicitly +create_person = unit_of_work(timeout=5.0)(lambda tx: tx.run( + "MERGE (p:Person {name: $name})", name="Alice" +)) +session.execute_write(create_person) +``` + +Use named functions when timeout or metadata is needed; lambdas are fine for fire-and-forget callbacks. + +## Multiple `tx.run()` Calls + +Calling `tx.run()` again before the first `Result` is consumed causes the driver to **buffer the first result in memory**. Safe, but can pull large results into RAM unexpectedly. + +```python +def multi_query_tx(tx): + people = [r["name"] for r in tx.run("MATCH (p:Person) RETURN p.name AS name")] + # first result consumed — safe to issue second query + for name in people: + tx.run("MERGE (:Person {name: $name})-[:VISITED]->(:City {name: 'London'})", name=name) + return len(people) +``` + +## Retry Safety + +`execute_read` / `execute_write` callbacks **may execute more than once** on transient failures — keep them side-effect-free. + +```python +# ❌ Side effect fires on every retry +def dangerous_tx(tx): + requests.post("https://api.example.com/notify") # fires on every retry + tx.run("CREATE (p:Person {name: $name})", name="Alice") + +# ✅ Database work only; HTTP call made after confirmed success +def safe_tx(tx): + tx.run("MERGE (p:Person {name: $name})", name="Alice") # idempotent + +session.execute_write(safe_tx) +requests.post("https://api.example.com/notify") # outside callback +``` + +## Repository Pattern + +```python +from neo4j import Driver, RoutingControl +from dataclasses import dataclass + +@dataclass +class Person: + name: str + age: int + +class PersonRepository: + def __init__(self, driver: Driver, database: str = "neo4j"): + self._driver = driver + self._db = database + + def find_by_name_prefix(self, prefix: str) -> list[Person]: + records, _, _ = self._driver.execute_query( + "MATCH (p:Person) WHERE p.name STARTS WITH $prefix RETURN p.name AS name, p.age AS age", + prefix=prefix, + routing_=RoutingControl.READ, + database_=self._db, + ) + return [Person(name=r["name"], age=r["age"]) for r in records] + + def create(self, person: Person) -> None: + self._driver.execute_query( + "CREATE (p:Person {name: $name, age: $age})", + name=person.name, age=person.age, + database_=self._db, + ) + + def bulk_create(self, people: list[Person]) -> None: + rows = [{"name": p.name, "age": p.age} for p in people] + self._driver.execute_query( + "UNWIND $rows AS row MERGE (p:Person {name: row.name}) SET p.age = row.age", + rows=rows, + database_=self._db, + ) +``` diff --git a/.agents/skills/neo4j-query-tuning-skill/README.md b/.agents/skills/neo4j-query-tuning-skill/README.md new file mode 100644 index 0000000000..36362f95a5 --- /dev/null +++ b/.agents/skills/neo4j-query-tuning-skill/README.md @@ -0,0 +1,31 @@ +# neo4j-query-tuning-skill + +Diagnoses and fixes slow Neo4j Cypher queries by interpreting execution plans, identifying bad operators, and prescribing targeted fixes. + +## What it covers + +- **EXPLAIN vs PROFILE** — when to use each; key metrics (dbHits, rows, estimatedRows, pageCacheHitRatio) +- **Execution plan operators** — complete reference table with good/bad signals and fix strategies +- **Cardinality estimation** — detecting stale stats, forcing replanning +- **Common plan problems** — missing indexes, CartesianProduct, Eager, over-traversal +- **Planner hints** — `USING INDEX`, `USING SCAN`, `USING JOIN ON` +- **Runtime selection** — slotted, pipelined, parallel; when each is appropriate +- **Query monitoring** — `SHOW QUERIES`, `SHOW TRANSACTIONS`, `TERMINATE TRANSACTION`, `db.stats.retrieve` + +## Availability + +Works with any Neo4j 2025.x / 2026.x instance (self-managed or Aura). Some features require Enterprise edition: +- `SHOW QUERIES` for other users' queries — Enterprise +- `runtime=parallel` — Enterprise or Aura Pro 2025+ + +## Install + +```bash +# Using Claude Code (agentskills.io): +/skill install neo4j-query-tuning-skill +``` + +## Reference Files + +- [`references/plan-operators.md`](references/plan-operators.md) — complete operator table with all variants +- [`references/stats-and-monitoring.md`](references/stats-and-monitoring.md) — SHOW QUERIES, SHOW TRANSACTIONS, db.stats.*, index health, page cache diff --git a/.agents/skills/neo4j-query-tuning-skill/SKILL.md b/.agents/skills/neo4j-query-tuning-skill/SKILL.md new file mode 100644 index 0000000000..cd04288bcd --- /dev/null +++ b/.agents/skills/neo4j-query-tuning-skill/SKILL.md @@ -0,0 +1,276 @@ +--- +name: neo4j-query-tuning-skill +description: Diagnoses and fixes slow Neo4j Cypher queries by reading execution plans, identifying + bad operators (AllNodesScan, CartesianProduct, Eager, NodeByLabelScan), and prescribing fixes + (indexes, hints, query rewrites, runtime selection). Use when a query is slow, when EXPLAIN + or PROFILE output needs interpretation, when dbHits or pageCacheHitRatio are poor, when + cardinality estimation diverges from actuals, or when deciding between slotted/pipelined/parallel + runtimes. Covers USING INDEX / USING SCAN / USING JOIN hints, db.stats.retrieve, SHOW QUERIES, + SHOW TRANSACTIONS, TERMINATE TRANSACTION. + Does NOT write new Cypher from scratch — use neo4j-cypher-skill. + Does NOT cover GDS algorithm tuning — use neo4j-gds-skill. + Does NOT cover index/constraint creation syntax details — use neo4j-cypher-skill references/indexes.md. +allowed-tools: Bash WebFetch +version: 1.0.1 +--- + +## When to Use +- Query takes unexpectedly long; need root-cause analysis +- EXPLAIN/PROFILE output in hand — needs interpretation +- Identifying which index is missing or unused +- Deciding between slotted / pipelined / parallel runtimes +- Monitoring live queries: SHOW QUERIES, SHOW TRANSACTIONS +- Cardinality estimates wrong (plan replanning needed) + +## When NOT to Use +- **Writing Cypher from scratch** → `neo4j-cypher-skill` +- **GDS algorithm performance** → `neo4j-gds-skill` +- **Schema design / data modelling** → `neo4j-modeling-skill` + +--- + +## EXPLAIN vs PROFILE + +| | EXPLAIN | PROFILE | +|---|---|---| +| Executes query? | No | Yes | +| Returns data? | No | Yes | +| Shows `rows` (actual) | No | Yes | +| Shows `dbHits` (actual) | No | Yes | +| Shows `estimatedRows` | Yes | Yes | +| Cost | Zero | Full query cost | + +Run `PROFILE` **twice** — first run warms page cache; second gives representative metrics. + +```cypher +EXPLAIN MATCH (p:Person {email: $email}) RETURN p.name +PROFILE MATCH (p:Person {email: $email}) RETURN p.name +``` + +Query API alternative (no driver): +```bash +curl -X POST https:///db//query/v2 \ + -u : -H "Content-Type: application/json" \ + -d '{"statement": "EXPLAIN MATCH (p:Person {email: $email}) RETURN p.name", "parameters": {"email": "a@b.com"}}' +``` + +--- + +## Key Plan Metrics + +| Metric | Good | Investigate if | +|---|---|---| +| `dbHits` | Low; drops after index added | High relative to `rows` | +| `rows` | Shrinks early in plan | Large until final operator | +| `estimatedRows` | Close to `rows` | >10× divergence from actual | +| `pageCacheHitRatio` | >0.99 | <0.90 (disk I/O bottleneck) | +| `pageCacheHits` | High | — | +| `pageCacheMisses` | Near 0 | Rising (page cache too small) | + +Read plans **bottom-up** — leaf operators at bottom initiate data retrieval. + +--- + +## Operator Reference + +| Operator | Good/Bad | Meaning | Fix | +|---|---|---|---| +| `NodeIndexSeek` | ✓ | Exact match via RANGE/LOOKUP index | — | +| `NodeUniqueIndexSeek` | ✓ | Unique constraint index hit | — | +| `NodeIndexContainsScan` | ✓ | TEXT index CONTAINS / STARTS WITH | — | +| `NodeIndexScan` | ~ | Full index scan (no predicate) | Add WHERE predicate or composite index | +| `NodeByLabelScan` | ✗ | Scans all nodes of label | Add RANGE index on lookup property | +| `AllNodesScan` | ✗✗ | Scans entire node store | Add label + index to MATCH | +| `Expand(All)` | ~ | Traverse relationships from node | Normal; limit with LIMIT or WHERE | +| `Expand(Into)` | ~ | Find rels between two matched nodes | Normal for known-endpoint joins | +| `Filter` | ~ | Predicate applied after scan | Move predicate into WHERE with index | +| `CartesianProduct` | ✗ | No join predicate between two MATCH | Add WHERE join or use WITH between MATCHes | +| `NodeHashJoin` | ~ | Hash join on node IDs | Normal; planner chose hash join | +| `ValueHashJoin` | ~ | Hash join on values | Normal; watch memory for large inputs | +| `EagerAggregation` | ~ | Full aggregation (ORDER BY, count(*)) | Normal for aggregates | +| `Aggregation` | ✓ | Streaming aggregation | — | +| `Eager` | ✗ | Read/write conflict; materialises all rows | See Eager fix strategies below | +| `Sort` | ~ | Full sort — O(n log n) | Add `LIMIT` before Sort; push LIMIT earlier | +| `Top` | ✓ | Sort+Limit combined — O(n log k) | Preferred over Sort+Limit | +| `Limit` | ✓ | Truncates rows early | Push as early as possible | +| `Skip` | ~ | Offset pagination | Use keyset pagination on large graphs | +| `ProduceResults` | — | Final output operator | Root of tree | +| `UndirectedRelationshipByIdSeekPipe` | ~ | Lookup by relationship ID | Avoid `id(r)` — use `elementId(r)` | + +Full operator reference → [references/plan-operators.md](references/plan-operators.md) + +--- + +## Diagnostic Workflow (Agent Runbook) + +### Step 1 — Baseline Plan +```cypher +EXPLAIN +``` +Scan output for `AllNodesScan`, `NodeByLabelScan`, `CartesianProduct`, `Eager`. + +### Step 2 — Check Indexes +```cypher +SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state +WHERE state = 'ONLINE' +``` +Find whether the label/property from the bad operator has an index. + +### Step 3 — Create Missing Index +```cypher +// RANGE index for equality/range predicates: +CREATE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email) +// TEXT index for CONTAINS/ENDS WITH: +CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio) +// Composite for multi-property lookup: +CREATE INDEX order_status_date IF NOT EXISTS FOR (n:Order) ON (n.status, n.createdAt) +``` +Wait for `state = 'ONLINE'` before measuring. + +### Step 4 — Profile After Fix +```cypher +PROFILE +``` +Compare `dbHits` and elapsed ms before/after. Target: `NodeIndexSeek` replaces scan operators. + +### Step 5 — Stale Statistics (if estimatedRows wildly off) +```cypher +CALL db.prepareForReplanning() +// or resample a specific index: +CALL db.resampleIndex("person_email") +// or resample all outdated: +CALL db.resampleOutdatedIndexes() +``` +Config: `dbms.cypher.statistics_divergence_threshold` (default `0.75` — plan expires when stat changes >75%). + +--- + +## Fixing Common Plan Problems + +### Missing Index → NodeByLabelScan / AllNodesScan +```cypher +// Force index hint when planner ignores it: +MATCH (p:Person {email: $email}) +USING INDEX p:Person(email) +RETURN p.name +// Force label scan (sometimes faster for high selectivity): +MATCH (p:Person {email: $email}) +USING SCAN p:Person +RETURN p.name +``` + +### Wrong Anchor — Planner Picks Wrong Starting Node +Reorder MATCH or use hints: +```cypher +// Force join at specific node: +MATCH (a:Author)-[:WROTE]->(b:Book)-[:IN_CATEGORY]->(c:Category {name: $cat}) +USING JOIN ON b +RETURN a.name, b.title +``` + +### CartesianProduct — Two Unconnected MATCHes +```cypher +// Bad (Cartesian product): +MATCH (a:Author {id: $aid}) +MATCH (b:Book {id: $bid}) +RETURN a.name, b.title + +// Good (explicit join or WITH): +MATCH (a:Author {id: $aid})-[:WROTE]->(b:Book {id: $bid}) +RETURN a.name, b.title +// Or: WITH between them to reset planning context +``` + +### Eager — Read/Write Conflict +Three strategies (pick simplest): +1. **Add specific labels** to MATCH nodes so planner distinguishes read/write sets +2. **Collect-then-write**: `WITH collect(n) AS nodes UNWIND nodes AS n SET n.x = 1` +3. **CALL IN TRANSACTIONS**: isolates each batch in its own transaction +```cypher +CYPHER 25 +MATCH (p:Person) WHERE p.score > 100 +CALL (p) { SET p.tier = 'gold' } IN TRANSACTIONS OF 1000 ROWS +``` + +### Expensive CONTAINS / ENDS WITH +```cypher +// Needs TEXT index (RANGE does NOT support these): +CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio) +MATCH (p:Person) WHERE p.bio CONTAINS $keyword RETURN p.name +``` + +### Over-Traversal — Push LIMIT Early +```cypher +// Bad: LIMIT after expensive join +MATCH (a:Author)-[:WROTE]->(b:Book)-[:REVIEWED_BY]->(r:Review) +RETURN a.name, b.title, r.text LIMIT 10 + +// Good: anchor limit before fan-out +MATCH (a:Author)-[:WROTE]->(b:Book) +WITH a, b LIMIT 10 +MATCH (b)-[:REVIEWED_BY]->(r:Review) +RETURN a.name, b.title, r.text +``` + +--- + +## Cypher Runtime Selection + +| Runtime | Select | Best For | Avoid When | +|---|---|---|---| +| `pipelined` | `CYPHER runtime=pipelined` | Default OLTP; streaming, low memory | Unsupported operators fall back to slotted | +| `slotted` | `CYPHER runtime=slotted` | Guaranteed stable behavior; debug | Performance-critical OLTP | +| `parallel` | `CYPHER 25 runtime=parallel` | Large analytical scans; aggregations | OLTP, writes, short queries, Aura Free | + +Pipelined is default for most queries. Parallel requires `dbms.cypher.parallel.worker_limit` configured; available on Enterprise and Aura Pro 2025+. + +```cypher +// Force parallel for large aggregation: +CYPHER 25 runtime=parallel +MATCH (n:Transaction) WHERE n.amount > 1000 +RETURN n.currency, count(*), sum(n.amount) +``` + +--- + +## Query Monitoring Commands + +```cypher +// Live queries + resource usage: +SHOW QUERIES YIELD query, queryId, elapsedTimeMillis, allocatedBytes, status, username + +// Running transactions: +SHOW TRANSACTIONS YIELD transactionId, currentQuery, currentQueryProgress, elapsedTime, status, username, cpuTime, activeLockCount // currentQueryProgress added [2026.03] + +// Kill a specific transaction: +TERMINATE TRANSACTION $transactionId + +// Kill a query: +TERMINATE QUERY $queryId + +// Graph count stats (node/rel counts by label/type — feed into planner): +CALL db.stats.retrieve('GRAPH COUNTS') YIELD section, data RETURN section, data + +// Token stats (label/property/rel-type IDs): +CALL db.stats.retrieve('TOKENS') YIELD section, data RETURN section, data +``` + +Full monitoring reference → [references/stats-and-monitoring.md](references/stats-and-monitoring.md) + +--- + +## Checklist + +- [ ] Run `EXPLAIN` first — identifies plan problems without execution cost +- [ ] Check for `AllNodesScan` / `NodeByLabelScan` — missing index +- [ ] Check for `CartesianProduct` — missing join predicate +- [ ] Check for `Eager` — read/write conflict +- [ ] `SHOW INDEXES` — confirm relevant index exists and `state = 'ONLINE'` +- [ ] Create missing index; wait for ONLINE +- [ ] Run `PROFILE` twice — first warms cache, second is representative +- [ ] Compare `dbHits` before/after fix +- [ ] If `estimatedRows` wildly off → `CALL db.prepareForReplanning()` +- [ ] Push `LIMIT` / `WITH n LIMIT k` before high-fanout operations +- [ ] For CONTAINS/ENDS WITH — TEXT index, not RANGE +- [ ] For large analytical queries — consider `runtime=parallel` +- [ ] Kill long-running queries with `TERMINATE TRANSACTION` diff --git a/.agents/skills/neo4j-query-tuning-skill/references/plan-operators.md b/.agents/skills/neo4j-query-tuning-skill/references/plan-operators.md new file mode 100644 index 0000000000..ecb9bdc87a --- /dev/null +++ b/.agents/skills/neo4j-query-tuning-skill/references/plan-operators.md @@ -0,0 +1,167 @@ +# Cypher Execution Plan Operators — Full Reference + +Read plans bottom-up: leaf operators at bottom, `ProduceResults` at top. + +**Lazy vs Eager**: Most operators stream rows to parent as produced. Eager operators (marked ✗ below) must consume *all* input before emitting output — they materialise the full row set and can cause OOM on large inputs. + +--- + +## Leaf Operators (data source) + +| Operator | Signal | Notes | +|---|---|---| +| `AllNodesScan` | ✗✗ Bad | Scans entire node store. No label → no label index. Add label + property index. | +| `NodeByLabelScan` | ✗ Bad | Scans all nodes of a label. No property index. Add RANGE index. | +| `NodeByIdSeek` | ✓ | Lookup by internal node ID. Fast but fragile — IDs are not stable. Use `elementId()`. | +| `NodeIndexSeek` | ✓ | Equality/range predicate satisfied via RANGE or LOOKUP index. Optimal. | +| `NodeUniqueIndexSeek` | ✓ | Unique constraint index hit. Optimal. | +| `NodeIndexScan` | ~ | Full scan of an index (no predicate selectivity). Faster than label scan; still linear. | +| `NodeIndexContainsScan` | ✓ | TEXT index CONTAINS/STARTS WITH. Requires TEXT index on property. | +| `NodeIndexEndsWithScan` | ✓ | TEXT index ENDS WITH. Requires TEXT index on property. | +| `RelationshipIndexSeek` | ✓ | Relationship property index hit. | +| `RelationshipByIdSeek` | ✓ | Lookup by relationship ID. | +| `DirectedRelationshipByIdSeek` | ✓ | Directed rel by ID. | +| `UndirectedRelationshipByIdSeek` | ~ | Undirected rel scan — matches twice (both directions). | +| `NodeByElementIdSeek` | ✓ | Lookup by `elementId()` string. Preferred over `id()`. | +| `Argument` | — | Passes outer scope variables into subquery. | + +--- + +## Traversal Operators + +| Operator | Signal | Notes | +|---|---|---| +| `Expand(All)` | ~ | Traverses all incoming/outgoing rels from a node. Normal. Limit fanout with WHERE/LIMIT. | +| `Expand(Into)` | ~ | Finds rels between two already-matched nodes. Efficient for known endpoints. | +| `OptionalExpand(All)` | ~ | OPTIONAL MATCH equivalent. Returns null row if no match. | +| `OptionalExpand(Into)` | ~ | Optional expand between known endpoints. | +| `VarLengthExpand(All)` | ✗ | Variable-length `(a)-[*1..5]->(b)` — can be expensive. Use QPE patterns or bound depth. | +| `VarLengthExpand(Pruning)` | ~ | Pruned variable-length — avoids re-visiting nodes. Better than All. | +| `BFSPruningVarLengthExpand` | ✓ | BFS-based; used for `SHORTEST` paths. Preferred. | +| `ShortestPath` | ~ | Single shortest path. Replaced by QPE in Cypher 25. | + +--- + +## Join Operators + +| Operator | Signal | Notes | +|---|---|---| +| `CartesianProduct` | ✗ Bad | Two unconnected MATCH branches joined without predicate. O(m×n). Add WHERE join. | +| `NodeHashJoin` | ~ | Hash join on node IDs. Eager — builds hash table. Memory-intensive for large inputs. | +| `ValueHashJoin` | ~ | Hash join on arbitrary values (e.g. property equality). Eager. | +| `TriadicSelection` | ✓ | Optimised "friend-of-friend excluding already-known" pattern. | +| `TriadicBuild` / `TriadicFilter` | ✓ | Components of triadic optimisation. | + +--- + +## Filter / Projection Operators + +| Operator | Signal | Notes | +|---|---|---| +| `Filter` | ~ | Applies predicate after scan/expand. Non-index-backed predicate. Move to index if possible. | +| `CacheProperties` | ✓ | Caches property values from store to avoid re-reads downstream. | +| `Projection` | — | Evaluates expressions for output columns. | +| `DropResult` | — | Discards results (e.g., write queries where RETURN is absent). | +| `ProduceResults` | — | Root operator — emits final rows to client. | + +--- + +## Aggregation Operators + +| Operator | Signal | Notes | +|---|---|---| +| `Aggregation` | ✓ | Streaming aggregation; no full materialisation needed. | +| `EagerAggregation` | ~ | Eager; must see all rows before emitting. Required for ORDER BY + aggregation. | +| `Distinct` | ~ | Deduplication. Eager on large inputs. Use `WITH DISTINCT` to push earlier. | +| `OrderedAggregation` | ✓ | Streaming aggregation when input is pre-sorted. | +| `OrderedDistinct` | ✓ | Streaming dedup when input is pre-sorted. | + +--- + +## Sort / Limit Operators + +| Operator | Signal | Notes | +|---|---|---| +| `Sort` | ✗ | Eager full sort — O(n log n). Materialises all rows. Add LIMIT to convert to Top. | +| `Top` | ✓ | Sort+Limit combined — O(n log k). Preferred; only keeps top k in memory. | +| `Top1` | ✓ | Single min/max — O(n). | +| `Limit` | ✓ | Truncates rows non-eagerly. Push as early as possible in the plan. | +| `Skip` | ~ | Offset pagination. Linear scan to skip position. Use keyset pagination for large offsets. | +| `PartialSort` | ~ | Sort within already-grouped prefix. More efficient than full Sort. | +| `PartialTop` | ✓ | Top within grouped prefix. | + +--- + +## Write Operators + +| Operator | Notes | +|---|---| +| `Create` | Creates nodes/rels. | +| `Merge` | Merge with lock semantics. Requires constraint for atomicity. | +| `SetProperty` / `SetProperties` | Sets properties. `SetProperties` batch-sets from map. | +| `SetLabels` / `RemoveLabels` | Label mutation. | +| `Delete` | Deletes node (fails if has rels). Use DetachDelete. | +| `DetachDelete` | Deletes node + all its rels. | +| `DeleteRelationship` | Deletes a relationship. | + +--- + +## Control / Subquery Operators + +| Operator | Notes | +|---|---| +| `Eager` | ✗✗ — Read/write conflict; materialises all upstream rows. Fix: add labels, collect-then-write, or CALL IN TRANSACTIONS. | +| `Apply` | Correlated subquery execution (CALL (x) { }). One inner execution per outer row. | +| `SemiApply` / `AntiSemiApply` | EXISTS { } / NOT EXISTS { } | +| `Optional` | OPTIONAL MATCH — passes null row if no match. | +| `ConditionalApply` | Subquery executed only if condition holds. | +| `AssertSameNode` | Verifies MERGE did not create duplicate (unique constraint enforcement). | +| `TransactionForeach` | CALL IN TRANSACTIONS outer loop. | +| `TransactionApply` | CALL IN TRANSACTIONS inner execution. | +| `Union` | Combines UNION branches. | +| `LoadCSV` | LOAD CSV row reader. | +| `Foreach` | FOREACH loop (write only, no RETURN). | + +--- + +## Reading the Plan: Worked Example + +``` +ProduceResults ← root; read last + | + Filter ← predicate not index-backed + | + Expand(All) ← traversal from matched node + | + NodeIndexSeek ← leaf; read first; index used ✓ +``` + +Index seek is efficient, expand is normal, filter applied after expand (not index-backed). If filter is selective, add a composite index or move the WHERE earlier. + +--- + +## Operator Hints + +```cypher +// Force index: +MATCH (p:Person {email: $email}) +USING INDEX p:Person(email) +RETURN p + +// Force label scan (ignore index): +MATCH (p:Person {active: true}) +USING SCAN p:Person +RETURN p + +// Force hash join at specific node: +MATCH (a:Author)-[:WROTE]->(b:Book)<-[:REVIEWED]-(r:Reviewer) +USING JOIN ON b +RETURN a.name, r.name + +// Force index for relationship property: +MATCH ()-[t:TRANSFER {txId: $id}]->() +USING INDEX t:TRANSFER(txId) +RETURN t +``` + +Multiple hints can be combined in one query. diff --git a/.agents/skills/neo4j-query-tuning-skill/references/stats-and-monitoring.md b/.agents/skills/neo4j-query-tuning-skill/references/stats-and-monitoring.md new file mode 100644 index 0000000000..9ef1afbae9 --- /dev/null +++ b/.agents/skills/neo4j-query-tuning-skill/references/stats-and-monitoring.md @@ -0,0 +1,195 @@ +# Stats and Monitoring — Reference + +## SHOW QUERIES + +Lists currently running queries across all databases (admin required for other users' queries). + +```cypher +SHOW QUERIES +YIELD query, queryId, database, username, elapsedTimeMillis, allocatedBytes, + status, activeLockCount, pageHits, pageFaults, protocol, connectionId +WHERE elapsedTimeMillis > 5000 +RETURN queryId, username, elapsedTimeMillis, allocatedBytes, query +ORDER BY elapsedTimeMillis DESC +``` + +Key fields: +- `queryId` — use with `TERMINATE QUERY` +- `elapsedTimeMillis` — wall time since query started +- `allocatedBytes` — heap allocated; high = memory pressure +- `status` — `running`, `planning`, `waiting`, `closing` +- `activeLockCount` — >0 means write transaction holding locks +- `pageHits` / `pageFaults` — cache hit/miss counts for this query + +Kill a single query: +```cypher +TERMINATE QUERY "query-id-string" +YIELD queryId, username, message +``` + +--- + +## SHOW TRANSACTIONS + +Lists all currently open transactions. + +```cypher +SHOW TRANSACTIONS +YIELD transactionId, database, username, currentQuery, elapsedTime, + status, cpuTime, waitTime, idleTime, activeLockCount, + pageHits, pageFaults, currentQueryId +WHERE status <> 'Terminated' +RETURN transactionId, username, status, elapsedTime, activeLockCount, currentQuery +ORDER BY elapsedTime DESC +``` + +Key fields: +- `transactionId` — use with `TERMINATE TRANSACTION` +- `status` — `Running`, `Blocked`, `Closing`, `Terminated` +- `activeLockCount` — transactions blocking others will have high counts +- `currentQuery` — the Cypher string currently executing (or last executed) +- `elapsedTime` — duration since transaction opened + +Terminate a transaction: +```cypher +TERMINATE TRANSACTION "neo4j-transaction-123" +YIELD transactionId, username, message +``` + +Terminate multiple: +```cypher +TERMINATE TRANSACTIONS "tx-1", "tx-2" +YIELD transactionId, message +``` + +--- + +## Database Statistics + +### Graph Counts +Node/relationship counts by label and type — the data the planner uses for cardinality estimation. + +```cypher +CALL db.stats.retrieve('GRAPH COUNTS') +YIELD section, data +RETURN section, data +``` + +`data` map includes keys like: +- `nodes` — total node count +- `relationships` — total rel count +- `nodesByLabel` — map of `{label: count}` +- `relsByType` — map of `{type: count}` +- `relsByTypeStartingLabel` / `relsByTypeEndingLabel` — selectivity data + +### Token Stats +```cypher +CALL db.stats.retrieve('TOKENS') +YIELD section, data +RETURN section, data +``` + +Returns internal token ID mappings for labels, property keys, and relationship types. + +### Retrieve All Stats +```cypher +CALL db.stats.retrieveAllAnonymized('GRAPH COUNTS') +YIELD section, data +RETURN section, data +``` +Anonymized version for sharing without exposing property names. + +--- + +## Statistics and Replanning + +### Config +`dbms.cypher.statistics_divergence_threshold` (default: `0.75`) + +Formula: `abs(a - b) / max(a, b)`. At 0.75, plan invalidated when statistics change by 75% (~4× growth/shrink). Lower to replan more aggressively on growing databases. + +### Force Replanning +```cypher +// Recalculate all statistics immediately (blocks until complete): +CALL db.prepareForReplanning() + +// Resample a specific index asynchronously: +CALL db.resampleIndex("index-name") + +// Resample all outdated indexes asynchronously: +CALL db.resampleOutdatedIndexes() +``` + +Force replanning of a single query without changing stats: +```cypher +CYPHER replan=force +MATCH (p:Person {email: $email}) RETURN p.name +``` + +Skip replanning (use cached plan even if stale — useful during high-load bursts): +```cypher +CYPHER replan=skip +MATCH (p:Person {email: $email}) RETURN p.name +``` + +--- + +## Index Health Check + +```cypher +// Indexes not yet ONLINE (still populating or failed): +SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state +WHERE state <> 'ONLINE' +RETURN name, type, labelsOrTypes, properties, state + +// All online indexes: +SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state, populationPercent +WHERE state = 'ONLINE' +RETURN name, type, labelsOrTypes, properties +ORDER BY type, labelsOrTypes +``` + +Index types and supported predicates: +| Type | `=` | `<>` | `<` `>` | `IN` | `STARTS WITH` | `CONTAINS` | `ENDS WITH` | `POINT` | +|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| RANGE | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | +| TEXT | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ | +| POINT | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | +| FULLTEXT | — | — | — | — | — | ✓ | — | — | +| LOOKUP | node/rel by ID | — | — | — | — | — | — | — | + +--- + +## Query Log (server-side) + +On self-managed Neo4j, slow queries log to `neo4j.log` and `query.log`: + +Config options (`neo4j.conf`): +``` +db.logs.query.enabled=INFO # Log all queries (verbose) or WARN (slow only) +db.logs.query.threshold=2s # Log queries taking longer than this +db.logs.query.parameter_logging_enabled=true +db.logs.query.allocation_logging_enabled=true +db.logs.query.page_logging_enabled=true +``` + +Each log entry includes: `{elapsedMs} ms: {query}` with optional params, allocated bytes, page hits/misses. + +--- + +## Page Cache Sizing + +Small page cache → high `pageFaults` → disk I/O → slow queries. + +```cypher +// Current page cache stats: +CALL dbms.queryJmx("org.neo4j:instance=kernel#0,name=Page cache") +YIELD attributes +RETURN attributes +``` + +Or from SHOW TRANSACTIONS/QUERIES: +- `pageHits` high, `pageFaults` low → cache is sufficient +- `pageFaults` > 1% of pageHits → increase `server.memory.pagecache.size` in `neo4j.conf` + +Set page cache to hold the entire graph store (`graph.db/` directory size). diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt index ab4eb1dcce..1eeed8736e 100644 --- a/.vale/styles/spelling-exceptions.txt +++ b/.vale/styles/spelling-exceptions.txt @@ -48,6 +48,7 @@ convert_query_response coroutine coroutines cosign +cosign's Cosign created_at created_by diff --git a/pyproject.toml b/pyproject.toml index fa67d849be..1db88baa58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -489,6 +489,13 @@ exclude = [ "examples", ] +# Vendored agent-skill scripts (installed via skills-lock.json) are third-party +# content and must not be linted against the project's ruleset. extend-exclude is +# used (not exclude) so it survives the CLI `--exclude` override CI passes. +extend-exclude = [ + ".agents/skills", +] + [tool.ruff.lint] preview = true @@ -1246,6 +1253,9 @@ python-version = "3.12" [tool.ty.src] exclude = [ "python_sdk/**", + # Vendored agent-skill scripts (installed via skills-lock.json) are + # third-party content and must not be type-checked against the project. + ".agents/skills/**", ] [[tool.ty.overrides]] diff --git a/skills-lock.json b/skills-lock.json index 9d7deea538..a16a4deca5 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -31,6 +31,24 @@ "skillPath": "opsmill-dev/skills/monitoring-pull-requests/SKILL.md", "computedHash": "b52cf3902ca8a290aa3167ff5e6b0a54068a45187357e342e5967f18fb00d854" }, + "neo4j-cypher-skill": { + "source": "neo4j-contrib/neo4j-skills", + "sourceType": "github", + "skillPath": "neo4j-cypher-skill/SKILL.md", + "computedHash": "1822c0a56aae0e1f3a704671dfae3370260a2489dda3598e8485b9a6733202ae" + }, + "neo4j-driver-python-skill": { + "source": "neo4j-contrib/neo4j-skills", + "sourceType": "github", + "skillPath": "neo4j-driver-python-skill/SKILL.md", + "computedHash": "daef535fd218c4c370c5bfe993d53c16ffbfd7ce57224e03b4890d282975d5ae" + }, + "neo4j-query-tuning-skill": { + "source": "neo4j-contrib/neo4j-skills", + "sourceType": "github", + "skillPath": "neo4j-query-tuning-skill/SKILL.md", + "computedHash": "203ba2a3dae704c3a22875c4d9eb86ef2943e04d923d5f17413a8a0f1ff6d198" + }, "opsmill-dev-analyzing-bugs": { "source": "opsmill/opsmill-skills", "sourceType": "github",