Chapter 05 · Cypher Expressions, Aggregation, UNWIND, Collections, Maps, and Subqueries

Compose a Multi-Stage Analytical Query with Parameters, Aggregation, Subqueries, and Explainable Intermediate Results

Compose a production-readable customer analytics query in stages: preserve one-customer grain, isolate correlated aggregates, bound collections, and prove both the Cartesian failure and its repair.

Intermediate → Advanced135–165 minutesChapter integration labNeo4j 2026.07.1 Community · Cypher 25Last reviewed: September 2026

Learning outcomes

AtlasMart's reporting API now needs a reviewable query that starts from customer filters, computes order and product summaries independently, applies a gross-spend threshold, and returns one bounded map per customer. The goal is not clever Cypher; it is an analytical pipeline whose cardinality and scope can be explained stage by stage.

01

Compose parameters, MATCH, CALL subqueries, aggregation, collections and map projections without losing the intended one-customer row grain.

02

Record expected intermediate cardinality at each stage and distinguish semantic fixtures from performance measurements.

03

Keep collection payloads bounded or summarized rather than using collect as an unbounded sink.

04

Reproduce a disconnected-product Cartesian bug and prove the repair using a correlated pattern/subquery.

05

Defend production choices around indexes, result size, driver streaming, timeouts and query observability.

Chapter 05 baseline · reviewed 9 September 2026

The mandatory lab continues the course baseline: Neo4j Community 2026.07.1, database neo4j, explicit CYPHER 25 on version-sensitive queries, authentication enabled, no mandatory APOC/GDS plugin, and the reusable AtlasMart Customer/Order/Product/Category fixture from Chapter 03. Neo4j 5.26.30 remains the LTS comparison line. Because GROUP BY was introduced for Cypher 25 in Neo4j 2026.07, examples that use it also show the implicit-grouping form needed for Cypher 5 compatibility.

Evidence, not invented output

This generation environment does not run Neo4j or Docker. Commands and semantics were checked against current Neo4j documentation, but plan operators, estimated rows, memory figures and wall-clock timings must be captured on the learner's own pinned instance. Deterministic fixture counts are stated only where they follow directly from the fixture.

Re-establish the deterministic AtlasMart read fixture

Every lesson can stand alone. The fixture is idempotent, so rerunning it is the safest reset: it restores four customers, five orders, four products, two categories, five PLACED, seven CONTAINS, and four IN_CATEGORY relationships without deleting the Chapter 04 operations graph. If your course sandbox already contains the fixture, this simply normalizes the properties used here.

Cypher · idempotent Chapter 05 analytical fixture
CYPHER 25CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.customerId IS UNIQUE;CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.productId IS UNIQUE;CREATE CONSTRAINT order_id IF NOT EXISTS FOR (o:Order) REQUIRE o.orderId IS UNIQUE;CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.categoryId IS UNIQUE;MERGE (c1:Customer {customerId:'C-1001'}) SET c1.name='Ava Chen', c1.tier='gold', c1.region='eu'MERGE (c2:Customer {customerId:'C-1002'}) SET c2.name='Noah Smith', c2.tier='silver', c2.region='us'MERGE (c3:Customer {customerId:'C-1003'}) SET c3.name='Mina Rahimi', c3.tier='gold', c3.region='me'MERGE (c4:Customer {customerId:'C-1004'}) SET c4.name='Leo Martin', c4.region='eu'MERGE (cat1:Category {categoryId:'CAT-CAMERAS'}) SET cat1.name='Cameras'MERGE (cat2:Category {categoryId:'CAT-AUDIO'}) SET cat2.name='Audio'MERGE (p1:Product {productId:'P-1001'}) SET p1.name='Trail Camera', p1.price=129.90, p1.rating=4.7MERGE (p2:Product {productId:'P-1002'}) SET p2.name='Studio Headphones', p2.price=89.00, p2.rating=4.7MERGE (p3:Product {productId:'P-1003'}) SET p3.name='Action Camera', p3.price=219.00, p3.rating=4.5MERGE (p4:Product {productId:'P-1004'}) SET p4.name='USB Microphone', p4.price=75.00MERGE (p1)-[:IN_CATEGORY]->(cat1)MERGE (p3)-[:IN_CATEGORY]->(cat1)MERGE (p2)-[:IN_CATEGORY]->(cat2)MERGE (p4)-[:IN_CATEGORY]->(cat2)MERGE (o1:Order {orderId:'O-2001'}) SET o1.placedAt=datetime('2026-08-01T09:00:00Z'), o1.status='paid', o1.total=218.90MERGE (o2:Order {orderId:'O-2002'}) SET o2.placedAt=datetime('2026-08-02T10:30:00Z'), o2.status='paid', o2.total=219.00MERGE (o3:Order {orderId:'O-2003'}) SET o3.placedAt=datetime('2026-08-03T12:00:00Z'), o3.status='shipped', o3.total=129.90MERGE (o4:Order {orderId:'O-2004'}) SET o4.placedAt=datetime('2026-08-04T14:15:00Z'), o4.status='paid', o4.total=164.00MERGE (o5:Order {orderId:'O-2005'}) SET o5.placedAt=datetime('2026-08-04T14:15:00Z'), o5.status='paid', o5.total=75.00MERGE (c1)-[:PLACED]->(o1)MERGE (c2)-[:PLACED]->(o2)MERGE (c1)-[:PLACED]->(o3)MERGE (c3)-[:PLACED]->(o4)MERGE (c4)-[:PLACED]->(o5)MERGE (o1)-[:CONTAINS {quantity:1}]->(p1)MERGE (o1)-[:CONTAINS {quantity:1}]->(p2)MERGE (o2)-[:CONTAINS {quantity:1}]->(p3)MERGE (o3)-[:CONTAINS {quantity:1}]->(p1)MERGE (o4)-[:CONTAINS {quantity:1}]->(p2)MERGE (o4)-[:CONTAINS {quantity:1}]->(p4)MERGE (o5)-[:CONTAINS {quantity:1}]->(p4);

Before analytical work, verify the relevant slice rather than assuming the whole database contains only these entities:

Cypher · fixture acceptance counts
CYPHER 25MATCH (c:Customer) WITH count(c) AS customersMATCH (o:Order) WITH customers, count(o) AS ordersMATCH (p:Product) WITH customers, orders, count(p) AS productsMATCH (:Customer)-[placed:PLACED]->(:Order)WITH customers, orders, products, count(placed) AS placedMATCH (:Order)-[line:CONTAINS]->(:Product)RETURN customers, orders, products, placed, count(line) AS contains;

For the fixture above the invariant is 4 / 5 / 4 / 5 / 7. If Chapter 04 remains loaded, the total database node/relationship counts will be larger; label- and type-specific counts are the contract for this chapter.

1. Write the result contract before the query

For this lab the endpoint accepts $region (nullable) and $minGross. It returns one map per qualifying customer with stable customer identity, name, region, order count, gross order total, line-unit count, and a deduplicated bounded set of product IDs. With $region = null and $minGross = 150.0, the deterministic fixture should qualify three customers: Ava, Noah and Mina. That expectation is a fixture invariant, not a benchmark.

Stage Expected row grain Fixture count
Customer anchor One row per Customer 4
Order aggregate CALL One row per Customer 4
Product aggregate CALL One row per Customer 4
gross >= 150 filter One row per qualifying Customer 3
Final projection One map per qualifying Customer 3

2. Compose two correlated one-row aggregates

Cypher · explainable multi-stage AtlasMart analytics
CYPHER 25MATCH (c:Customer)WHERE $region IS NULL OR c.region = $regionCALL (c) {  MATCH (c)-[:PLACED]->(o:Order)  RETURN count(o) AS orderCount,         round(sum(o.total), 2) AS gross,         min(o.placedAt) AS firstOrderAt,         max(o.placedAt) AS lastOrderAt}CALL (c) {  MATCH (c)-[:PLACED]->(:Order)-[line:CONTAINS]->(p:Product)  RETURN sum(line.quantity) AS units,         collect(DISTINCT p.productId) AS productIds}WITH c, orderCount, gross, firstOrderAt, lastOrderAt, units, productIdsWHERE gross >= $minGrossRETURN c{  .customerId,  .name,  .region,  orderCount: orderCount,  gross: gross,  firstOrderAt: firstOrderAt,  lastOrderAt: lastOrderAt,  units: units,  productIds: productIds} AS customerSummaryORDER BY customerSummary.gross DESC, customerSummary.customerId;

Each CALL (c) is deliberately aggregated to one row before returning. This keeps the outer grain stable. The product list uses DISTINCT because the response contract asks for unique product IDs, while units preserves relationship quantity separately.

3. Make intermediate evidence executable

Do not debug only the final query. Run each stage independently with a known customer. For C-1001, the fixture contains two orders and three CONTAINS relationships across those orders. The snippets below prove the row source before aggregation.

Cypher · stage A, raw order rows
CYPHER 25MATCH (:Customer {customerId:'C-1001'})-[:PLACED]->(o:Order)RETURN o.orderId, o.totalORDER BY o.orderId;
Cypher · stage B, raw product-line rows
CYPHER 25MATCH (:Customer {customerId:'C-1001'})-[:PLACED]->(o:Order)-[line:CONTAINS]->(p:Product)RETURN o.orderId, p.productId, line.quantityORDER BY o.orderId, p.productId;

Only after those row sources are understood should the aggregates be trusted. On a larger graph, use EXPLAIN before execution and PROFILE on safe representative workloads; record actual rows, database hits and memory-related evidence rather than copying plan numbers from this course.

4. Deliberately wrong: disconnected catalog expansion

The following query looks like it is building a customer summary, but the disconnected (p:Product) multiplies each customer-order row by every product in the catalog before aggregation.

Wrong · final DISTINCT hides unrelated expansion
CYPHER 25MATCH (c:Customer)-[:PLACED]->(o:Order), (p:Product)RETURN c.customerId AS customerId,       count(DISTINCT o) AS orderCount,       collect(DISTINCT p.productId) AS productIds;

count(DISTINCT o) can make the order count look correct, and collect(DISTINCT p.productId) returns a tidy list—but it is the entire catalog for every customer. The repair is not “add more DISTINCT.” It is to connect products through (o)-[:CONTAINS]->(p) or isolate an actually independent catalog statistic in CALL ().

Repair · product rows are correlated to the customer orders
CYPHER 25MATCH (c:Customer)-[:PLACED]->(o:Order)-[:CONTAINS]->(p:Product)RETURN c.customerId AS customerId,       count(DISTINCT o) AS orderCount,       collect(DISTINCT p.productId) AS purchasedProductIdsORDER BY customerId;

5. Add an EXISTS gate when the API has a boolean prerequisite

If a caller asks for summaries only for customers who have ever bought a camera, filter with an existence condition before doing heavier per-customer aggregation. This states the intent directly and avoids exporting every qualifying path merely to deduplicate the customer later.

Cypher · boolean graph prerequisite
CYPHER 25MATCH (c:Customer)WHERE EXISTS {  MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(:Product)-[:IN_CATEGORY]->(:Category {categoryId:'CAT-CAMERAS'})}RETURN c.customerId, c.nameORDER BY c.customerId;

6. Lab verification matrix

Test Input What to verify
Baseline $region=null, $minGross=150.0 Three final customer maps; one row per customer.
Regional filter $region="eu" Only EU customers enter the subqueries.
High threshold $minGross=1000.0 Zero final rows without changing graph state.
Cartesian fault Run disconnected product variant Intermediate customer-order × catalog multiplication is visible in the plan/row reasoning.
Repair Run correlated product variant Product IDs are purchase-derived, not catalog-wide.

Because this is read-only, cleanup is simply parameter reset. If fixture state has drifted, rerun the idempotent fixture. Do not delete unrelated graph data or the Chapter 04 handoff graph.

Production judgment

This query shape is maintainable because every stage has an explicit grain and correlation, not because subqueries are always faster. On production data, evaluate anchor selectivity, indexes on stable domain identifiers and commonly filtered properties, graph degree, order-history distribution, size of collect(DISTINCT ...), transaction/query memory, driver fetch/stream behavior, timeouts, cancellation and response-size limits. A reporting endpoint that grows toward unbounded history or large cross-customer analytics may belong in a separate analytical architecture rather than an ever-larger transactional Cypher request.

Chapter 06 moves from reads to writes. The same discipline becomes more important there: row multiplication can create duplicate mutations, retries can repeat side effects, and MERGE is not a universal SQL upsert.

Check your understanding

  1. Why do both CALL subqueries aggregate before returning?
  2. Why is DISTINCT appropriate for productIds but not a general repair strategy?
  3. What does the $minGross=150 fixture test prove?
  4. Why can EXISTS reduce conceptual work for a boolean prerequisite?
  5. What is the key review question before adding another collect()?
Review the answers

1. To collapse their internal expansion to one row per incoming customer and preserve the outer customer grain.

2. The API asks for unique purchased product IDs; DISTINCT cannot repair a disconnected pattern or wrong semantics.

3. Only deterministic query correctness on the tiny fixture; it does not prove production performance.

4. It asks only whether at least one qualifying pattern exists instead of exporting every matching path.

5. Whether the collection is bounded and genuinely required by the API, and what memory/payload growth its cardinality implies.

Summary and next step

Chapter 05 closes with a compositional rule: know the row grain entering every clause, know which values are lists or maps, know exactly what a subquery imports and returns, and treat cardinality as an observable design property. Next, apply that discipline to graph mutations, idempotency and concurrency in Chapter 06.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.