Graph Database Africa · One Customer, Many Records · live demos

Graph Lab

The three live demos from One Customer, Many Records, with you in control. They play on their own; click or use the arrow keys to take over.

click or → next step ← previous Autoplay resumes after 30 seconds idle

Click inside the demo once so it receives your arrow keys. All people, emails and phone numbers are fictional.

Run the demo yourself in Neo4j Cypher · Aura Free

Paste into the Aura query editor one block at a time. Expected results are in the comments. Download .cypher

// =====================================================================
// One Customer, Many Records — Customer 360 + recommendations demo
// Graph Database Africa · Neo4j 5.26+ / 2026.x (Aura Free works)
// All people, emails and phone numbers are fictional.
// Run one block at a time (separate statements with the ; shown).
// =====================================================================

// ---------- 0. Reset (demo database only!) and constraints ----------
MATCH (n) DETACH DELETE n;
CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE;
CREATE CONSTRAINT person_id   IF NOT EXISTS FOR (p:Person)   REQUIRE p.id IS UNIQUE;
CREATE CONSTRAINT product_sku IF NOT EXISTS FOR (p:Product)  REQUIRE p.sku IS UNIQUE;

// ---------- 1. Source records from three silos ----------
// Identifiers become their own nodes (one node per value).
UNWIND [
 {id:'CRM-001',  src:'CRM',     name:'Thandi Mokoena', email:'thandi.m@example.com', phone:'+27825550101', device:null},
 {id:'WEB-7781', src:'SHOP',    name:'T. Mokoena',     email:'thandi.m@example.com', phone:null,           device:'DEV-A1'},
 {id:'SUP-3302', src:'SUPPORT', name:'Thandi M',       email:null,                   phone:'+27825550101', device:'DEV-A1'},
 {id:'CRM-002',  src:'CRM',     name:'Sipho Dlamini',  email:'sipho.d@example.com',  phone:null,           device:null},
 {id:'WEB-7790', src:'SHOP',    name:'Sipho Dlamini',  email:'sipho.d@example.com',  phone:null,           device:'DEV-B2'},
 {id:'SUP-3310', src:'SUPPORT', name:'S. Dlamini',     email:null,                   phone:null,           device:'DEV-B2'},
 {id:'WEB-8041', src:'SHOP',    name:'Lerato Naidoo',  email:null,                   phone:null,           device:'DEV-C3'},
 {id:'SUP-3321', src:'SUPPORT', name:'Lerato N',       email:null,                   phone:null,           device:'DEV-C3'},
 {id:'WEB-8100', src:'SHOP',    name:'Ayesha Khan',    email:'ayesha.k@example.com', phone:null,           device:null},
 {id:'WEB-8200', src:'SHOP',    name:'Bongani Zulu',   email:null,                   phone:null,           device:null}
] AS r
MERGE (c:Customer {id:r.id}) SET c.source = r.src, c.name = r.name
FOREACH (_ IN CASE WHEN r.email  IS NULL THEN [] ELSE [1] END |
  MERGE (e:Email  {address:r.email}) MERGE (c)-[:HAS_EMAIL]->(e))
FOREACH (_ IN CASE WHEN r.phone  IS NULL THEN [] ELSE [1] END |
  MERGE (p:Phone  {number:r.phone})  MERGE (c)-[:HAS_PHONE]->(p))
FOREACH (_ IN CASE WHEN r.device IS NULL THEN [] ELSE [1] END |
  MERGE (d:Device {id:r.device})     MERGE (c)-[:USES_DEVICE]->(d));

// ---------- 2. The trap: support logged three people with the call-centre line ----------
MATCH (c:Customer) WHERE c.id IN ['SUP-3302','SUP-3310','SUP-3321']
MERGE (p:Phone {number:'+27860100200'}) SET p.note = 'call centre'
MERGE (c)-[:HAS_PHONE]->(p);

// ---------- 3. Products and purchases (recorded against source records) ----------
UNWIND [['P-100','Trail Running Shoes'],['P-101','Hydration Vest'],['P-102','Energy Gels'],
        ['P-103','GPS Watch'],['P-104','Headlamp'],['P-105','Yoga Mat'],['P-106','Water Bottle']] AS p
MERGE (:Product {sku:p[0], name:p[1]});

UNWIND [['WEB-7781','P-100'],['WEB-7781','P-102'],
        ['WEB-7790','P-100'],['WEB-7790','P-101'],['WEB-7790','P-104'],
        ['WEB-8041','P-100'],['WEB-8041','P-101'],['WEB-8041','P-103'],
        ['WEB-8100','P-102'],['WEB-8100','P-101'],['WEB-8100','P-105'],
        ['WEB-8200','P-105'],['WEB-8200','P-106']] AS x
MATCH (c:Customer {id:x[0]}), (p:Product {sku:x[1]})
MERGE (c)-[:PURCHASED]->(p);

// ---------- 4. Order, duplicate payment, refund and support ticket (for the assistant demo) ----------
MATCH (c:Customer {id:'WEB-7781'}), (s:Product {sku:'P-100'})
MERGE (o:Order {id:'ORD-551'}) MERGE (c)-[:PLACED]->(o) MERGE (o)-[:CONTAINS]->(s)
MERGE (p1:Payment {id:'PAY-901'}) SET p1.amount = 1899
MERGE (p2:Payment {id:'PAY-902'}) SET p2.amount = 1899, p2.note = 'duplicate'
MERGE (p1)-[:FOR]->(o) MERGE (p2)-[:FOR]->(o)
MERGE (r:Refund {id:'REF-77'}) SET r.status = 'pending'
MERGE (r)-[:REFUNDS]->(p2);

MATCH (c:Customer {id:'SUP-3302'}), (o:Order {id:'ORD-551'})
MERGE (t:Ticket {id:'TCK-1'}) SET t.topic = 'Size exchange', t.status = 'shipped'
MERGE (c)-[:LOGGED]->(t) MERGE (t)-[:ABOUT]->(o);

// ---------- 5a. Show the trap (read-only): without a guard, strangers link up ----------
MATCH (a:Customer)-[:HAS_EMAIL|HAS_PHONE|USES_DEVICE]->(i)<-[:HAS_EMAIL|HAS_PHONE|USES_DEVICE]-(b:Customer)
WHERE a.id < b.id
RETURN a.id, b.id, labels(i)[0] AS via, coalesce(i.address, i.number, i.id) AS value
ORDER BY via, a.id;
// Expect pairs like SUP-3302 / SUP-3310 via the call-centre phone. Those are different people.

// ---------- 5b. Deterministic matching with a supernode guard ----------
// Skip identifiers shared by more than 2 records (the call-centre line has 3).
MATCH (a:Customer)-[:HAS_EMAIL|HAS_PHONE|USES_DEVICE]->(i)<-[:HAS_EMAIL|HAS_PHONE|USES_DEVICE]-(b:Customer)
WHERE a.id < b.id AND COUNT { (i)<--() } <= 2
WITH a, b, count(DISTINCT i) AS strength
MERGE (a)-[m:SAME_AS]->(b) SET m.strength = strength;

// ---------- 6. Golden records (Cypher version; use GDS WCC at scale, block 12) ----------
MATCH (c:Customer)
OPTIONAL MATCH (c)-[:SAME_AS]-+(o:Customer)
WITH c, [c] + collect(DISTINCT o) AS cluster
WITH c, reduce(k = c.id, x IN cluster | CASE WHEN x.id < k THEN x.id ELSE k END) AS key
MERGE (p:Person {id:'PER-' + key})
MERGE (c)-[:RESOLVES_TO]->(p);

// Survivorship: CRM wins the name, then the shop; keep provenance
MATCH (p:Person)<-[:RESOLVES_TO]-(c:Customer)
WITH p, collect(c) AS recs
SET p.name    = coalesce(head([r IN recs WHERE r.source = 'CRM'  | r.name]),
                         head([r IN recs WHERE r.source = 'SHOP' | r.name]), recs[0].name),
    p.sources = [r IN recs | r.source + ':' + r.id];

MATCH (p:Person) RETURN p.id, p.name, p.sources ORDER BY p.id;
// Expect 5 people from 10 records:
//   PER-CRM-001 Thandi Mokoena (CRM, SHOP, SUPPORT)   PER-CRM-002 Sipho Dlamini (CRM, SHOP, SUPPORT)
//   PER-SUP-3321 Lerato Naidoo (SHOP, SUPPORT)        PER-WEB-8100 Ayesha Khan   PER-WEB-8200 Bongani Zulu

// ---------- 7. Consent lives in the graph ----------
MATCH (p:Person {id:'PER-CRM-001'})
MERGE (k:Consent {id:'CONSENT-PER-CRM-001'}) SET k.directMarketing = false, k.recorded = date('2026-03-01')
MERGE (p)-[:HAS_CONSENT]->(k);

// ---------- 8. Customer 360: everything about Thandi, across all three systems ----------
MATCH (p:Person {id:'PER-CRM-001'})<-[:RESOLVES_TO]-(c:Customer)-[rel:PURCHASED|PLACED|LOGGED]->(x)
RETURN p.name, c.source, c.id, type(rel) AS interaction,
       coalesce(x.name, x.topic, x.id) AS item
ORDER BY c.source;

// ---------- 9. The assistant's bounded query ("charged twice?") ----------
MATCH (:Person {id:'PER-CRM-001'})<-[:RESOLVES_TO]-(c)-[:PLACED]->(o:Order)
      -[:CONTAINS]->(:Product {name:'Trail Running Shoes'})
OPTIONAL MATCH (o)<-[:FOR]-(pay:Payment)
OPTIONAL MATCH (ref:Refund)-[:REFUNDS]->(pay)
OPTIONAL MATCH (t:Ticket)-[:ABOUT]->(o)
RETURN o.id, collect(DISTINCT pay.id) AS payments, collect(DISTINCT ref.id + ' ' + ref.status) AS refunds,
       collect(DISTINCT t.id + ' ' + t.status) AS tickets;

// Consent check before any marketing
MATCH (:Person {id:'PER-CRM-001'})-[:HAS_CONSENT]->(k:Consent)
RETURN k.directMarketing AS mayMarket;   // false

// ---------- 10. User-based collaborative filtering, popularity-damped ----------
MATCH (t:Person {id:'PER-CRM-001'})<-[:RESOLVES_TO]-()-[:PURCHASED]->(p:Product)
WITH t, collect(DISTINCT p) AS owned
UNWIND owned AS p
MATCH (p)<-[:PURCHASED]-()-[:RESOLVES_TO]->(peer:Person)
WHERE peer <> t
WITH owned, peer, count(DISTINCT p) AS overlap
MATCH (peer)<-[:RESOLVES_TO]-()-[:PURCHASED]->(rec:Product)
WHERE NOT rec IN owned
WITH rec, sum(overlap) AS score, COUNT { (rec)<-[:PURCHASED]-() } AS pop
RETURN rec.name, score, pop, round(score / log(2 + pop), 3) AS damped
ORDER BY damped DESC LIMIT 5;
// Expect: Hydration Vest 1.864, Headlamp 0.91, GPS Watch 0.91, Yoga Mat 0.721

// ---------- 10b. Experiment: make the water bottle a best-seller, then rerun block 10 ----------
UNWIND ['WEB-7790','WEB-8041','WEB-8100'] AS id
MATCH (c:Customer {id:id}), (b:Product {sku:'P-106'})
MERGE (c)-[:PURCHASED]->(b);
// Rerun block 10: raw score ties Vest and Water Bottle at 3; damped keeps Vest first (1.864 vs 1.674).

// ---------- 11. Item-to-item: "bought X, also bought Y" (Jaccard over people) ----------
MATCH (x:Product {sku:'P-100'})<-[:PURCHASED]-()-[:RESOLVES_TO]->(b:Person)
WITH x, collect(DISTINCT b) AS buyersX
MATCH (y:Product)<-[:PURCHASED]-()-[:RESOLVES_TO]->(b2:Person) WHERE y <> x
WITH buyersX, y, collect(DISTINCT b2) AS buyersY
WITH y, size([b IN buyersY WHERE b IN buyersX]) AS shared, size(buyersX) + size(buyersY) AS total
WHERE shared > 0
RETURN y.name, round(toFloat(shared) / (total - shared), 3) AS jaccard
ORDER BY jaccard DESC;

// ---------- 12. (Needs Graph Data Science) WCC identity clusters with the same guard ----------
MATCH (c:Customer)-[:HAS_EMAIL|HAS_PHONE|USES_DEVICE]->(i)
WHERE COUNT { (i)<--() } <= 2
WITH gds.graph.project('ids', c, i, {}, {undirectedRelationshipTypes:['*']}) AS g
RETURN g.nodeCount, g.relationshipCount;

CALL gds.wcc.stream('ids') YIELD nodeId, componentId
WITH gds.util.asNode(nodeId) AS n, componentId WHERE n:Customer
RETURN componentId, collect(n.id) AS records ORDER BY size(records) DESC;

CALL gds.graph.drop('ids');
// Note: records with no identifiers (WEB-8200) are not in this projection; Cypher block 6 handles them.