LoraDB Query Cookbook
A scenario-driven companion to the clause-by-clause Queries reference. Reach for this page when you know the question ("who are my mutual follows?", "which orders are late?") but aren't sure how to shape the Cypher. Where the reference answers "what does this clause do", the cookbook answers "how do I ask this question".
Each recipe names a real problem, states its assumed data model,
gives a query, and explains why it works — then lists useful
variations and related concepts. Recipes are grouped by domain:
social, e-commerce, events, geospatial, vector retrieval. Every
query is idiomatic LoraDB — no external utility procedures, no CALL, no window functions
— and when a SQL idiom doesn't translate, the recipe shows the
Cypher-native shape.
On this page
- Social graph patterns
- E-commerce patterns
- Event / time-based patterns
- Geospatial patterns
- Vector-retrieval patterns
- Backup and restore
- See also
Social graph patterns
Recipe: Friends of friends
Problem
"For user $id, list people they don't follow yet, but who are
followed by somebody they do follow."
Assumed data model
:User {id, handle}(u:User)-[:FOLLOWS]->(v:User)— directed "u follows v"
Query
MATCH (me:User {id: $id})-[:FOLLOWS]->(friend:User)-[:FOLLOWS]->(candidate:User)
WHERE candidate <> me
AND NOT EXISTS { (me)-[:FOLLOWS]->(candidate) }
RETURN candidate.handle,
count(*) AS shared_paths
ORDER BY shared_paths DESC
LIMIT 20Explanation
The two-hop pattern enumerates every (me, friend, candidate)
triple. NOT EXISTS removes candidates me already follows;
candidate <> me removes self-loops. count(*) becomes the number
of mutual friends — a useful ranking signal.
Variations
- Also consider followers: reverse the last segment to
(candidate)-[:FOLLOWS]->(friend). - Combine both directions with a
UNIONand dedup withcollect(DISTINCT …). - Weight by recency of
friend's follow using a:FOLLOWS {since}property — see the e-commerce co-purchase recipe.
Related concepts
Recipe: Mutual connections
Problem
"List pairs of users who follow each other."
Assumed data model
:User(a)-[:FOLLOWS]->(b)— directed
Query
MATCH (a:User)-[:FOLLOWS]->(b:User)-[:FOLLOWS]->(a)
WHERE id(a) < id(b)
RETURN a.handle, b.handleExplanation
The pattern reads "a follows b and b follows a" — a 2-cycle. The
id(a) < id(b) predicate keeps one row per unordered pair; without
it you'd get both (alice, bob) and (bob, alice).
Variations
- Add a property filter: only mutuals where both accounts are
active — add
WHERE a.active AND b.active. - Rank by "oldest mutuals first" by collecting the relationship:
MATCH (a)-[r1:FOLLOWS]->(b)-[r2:FOLLOWS]->(a)and order bymin(r1.since, r2.since)— see ORDER BY.
Related concepts
Recipe: Recommendations via second-degree connections
Problem
"Recommend accounts for $id to follow, ranked by how many of their
current follows already follow that account."
Assumed data model
:User(u)-[:FOLLOWS]->(v)
Query
MATCH (me:User {id: $id})-[:FOLLOWS]->(:User)-[:FOLLOWS]->(rec:User)
WHERE rec <> me
AND NOT EXISTS { (me)-[:FOLLOWS]->(rec) }
RETURN rec.handle,
count(*) AS score,
collect(DISTINCT rec.country)[..3] AS sample_countries
ORDER BY score DESC
LIMIT 10Explanation
Similar to friends-of-friends but
projects a richer result — the count becomes a recommendation score,
and we collect a diverse sample of metadata. [..3] slices the
collected list to the first three entries — a
list slice.
Variations
- Add a CASE-based boost for accounts with a
verifiedbadge:count(CASE WHEN rec.verified THEN 1 END) * 2 + count(*). - Expand to 3-hop with
[:FOLLOWS*2..3]— bound it to avoid run-away — see Paths → variable-length.
Related concepts
Recipe: Influence score (weighted walk)
Problem
"For each user, estimate an influence score based on how many followers reach them within two hops."
Assumed data model
:User(follower)-[:FOLLOWS]->(followee)— standard directed follow
Query
MATCH (u:User)
OPTIONAL MATCH (u)<-[:FOLLOWS*1..2]-(reacher:User)
RETURN u.handle,
count(DISTINCT reacher) AS reach
ORDER BY reach DESC
LIMIT 20Explanation
OPTIONAL MATCH preserves users with zero reach. count(DISTINCT reacher) avoids double-counting any user reached through multiple
paths. Bounded at two hops to stay tractable.
Variations
- Replace
reacherwithcount(DISTINCT path.length(p))paths of different lengths to reveal reach-at-each-distance. - Combine with
:FOLLOWS {since}weights:sum(math.exp(-temporal.in_days(r.since, temporal.today()) * 0.02))whensinceis aDATE.
Related concepts
E-commerce patterns
Recipe: Top N products by revenue
Problem
"The ten products with the highest paid-order revenue this month."
Assumed data model
:Product {id, name, price}:Order {id, placed_at, status}(o)-[:CONTAINS {quantity}]->(p)
Query
MATCH (o:Order)-[c:CONTAINS]->(p:Product)
WHERE o.status = 'paid'
AND o.placed_at >= temporal.truncate('month', temporal.today())
RETURN p.name,
sum(c.quantity * p.price) AS revenue,
sum(c.quantity) AS units,
count(DISTINCT o) AS orders
ORDER BY revenue DESC
LIMIT 10Explanation
Joins orders and products through the :CONTAINS edge, filters to
paid orders in the current month, then aggregates revenue per
product. count(DISTINCT o) captures orders (an order can contain
many items), distinct from the total units sold.
Variations
- Top-N per category: group by
p.categoryas well and then pick-first within each usingcollect(…)[..1]. - Year-to-date: replace the date filter with
o.placed_at >= temporal.truncate('year', temporal.today()).
Related concepts
Recipe: Co-purchase patterns
Problem
"Which products are frequently bought together with $sku?"
Assumed data model
:Product {sku}:Order(o)-[:CONTAINS]->(p)
Query
MATCH (anchor:Product {sku: $sku})<-[:CONTAINS]-(:Order)-[:CONTAINS]->(other:Product)
WHERE other <> anchor
RETURN other.sku,
count(*) AS co_orders
ORDER BY co_orders DESC
LIMIT 20Explanation
Traverses orders that contain the anchor product and jumps to other
products in the same order. count(*) is the number of orders in
which the co-occurrence happened.
Variations
- Only ship-completed orders: add
WHERE o.status = 'shipped'after binding(o:Order). - Lift score over per-product baseline:
count(*) * 1.0 / ((MATCH (:Order)-[:CONTAINS]->(anchor)…))— two stages combined viaWITH— see Aggregation → pipeline aggregation.
Related concepts
Recipe: Repeat buyers
Problem
"Users who placed more than one paid order."
Assumed data model
:User:Order {status}(u)-[:PLACED]->(o)
Query
MATCH (u:User)-[:PLACED]->(o:Order {status: 'paid'})
WITH u, count(o) AS orders
WHERE orders > 1
RETURN u.email,
orders
ORDER BY orders DESCExplanation
Classic HAVING-style pipeline: aggregate first, then filter the
aggregated column. Cypher has no HAVING keyword — pipe through
WITH instead.
Variations
- Lifetime value: replace
count(o)withsum(o.amount). - Last-purchase recency: add
max(o.placed_at) AS lastand filter on that.
Related concepts
Recipe: Cart abandonment
Problem
"Users with an open cart containing more than $n items, but no
order placed in the last 30 days."
Assumed data model
:User:Cart {status},:Item(u)-[:HAS_CART]->(c),(c)-[:CONTAINS]->(:Item),(u)-[:PLACED]->(:Order)
Query
MATCH (u:User)-[:HAS_CART]->(c:Cart {status: 'open'})-[:CONTAINS]->(i:Item)
WITH u, c, count(i) AS items
WHERE items > $n
OPTIONAL MATCH (u)-[:PLACED]->(o:Order)
WITH u, c, items, max(o.placed_at) AS last_order_at
WHERE last_order_at IS NULL
OR last_order_at < temporal.now() - 'P30D'::DURATION
RETURN u.email, c.id, items
ORDER BY items DESCExplanation
The NOT EXISTS { } sub-pattern is an anti-join — keeps users
without any recent order. The surrounding pipeline filters open
carts above the threshold size.
Variations
- Add recency of cart update:
AND c.updated_at >= temporal.now() - 'P7D'::DURATIONfor "stale but not abandoned". - Compute total cart value:
sum(i.price * i.quantity) AS total.
Related concepts
Event / time-based patterns
Recipe: Attendees per event
Problem
"Total and unique attendees for each upcoming event, plus a flag for whether the event is sold out."
Assumed data model
:Event {id, starts_at, capacity}:User(u)-[:RSVP {status}]->(e)
Query
MATCH (e:Event)
WHERE e.starts_at >= temporal.now()
OPTIONAL MATCH (u:User)-[r:RSVP {status: 'yes'}]->(e)
WITH e, count(u) AS going
RETURN e.id,
e.starts_at,
going,
e.capacity,
CASE
WHEN going >= e.capacity THEN 'sold_out'
WHEN going >= e.capacity * 0.8 THEN 'filling'
ELSE 'open'
END AS status
ORDER BY e.starts_atExplanation
OPTIONAL MATCH ensures events with zero RSVPs still appear.
CASE computes a tiered
status per event.
Variations
- Break out by ticket tier:
collect(DISTINCT r.tier) AS tiers. - Include "maybe" RSVPs separately:
count(CASE WHEN r.status = 'maybe' THEN 1 END) AS maybes.