← Back to LDBC SNB Dataset

Neo4j Implementation

Deployment and query logic for Neo4j Enterprise 2026.05.0, with Graph Data Science 2026.05.0 and APOC 2026.05.0.

Schema Diagram and Graph Statistics

LDBC SNB Neo4j Schema

Scale factor 100 of the LDBC Social Network Benchmark, Business Intelligence workload. Only the initial_snapshot is loaded; the inserts and deletes update streams are not applied, because this implementation runs read-only queries. The graph is therefore a fixed snapshot whose latest creationDate is 2012-11-29T02:52:42.849+00:00.

Several labels in the table below describe the same nodes, so the node total is the sum of the eight base labels only. Comment and Post both carry the Message label, which is what lets the queries match (:Message) across the two, so the Message row is the sum of those two rather than an additional set of nodes. Place and Organisation take a concrete second label from a :LABEL column in the source CSV, so City + Country + Continent are the Place nodes and University + Company are the Organisation nodes.

Node Statistics

Node Label Count
Message 249,524,693
Comment 185,495,476
Post 64,029,217
Forum 4,611,436
Person 473,001
Tag 16,080
Organisation 7,955
University 6,380
Company 1,575
Place 1,460
City 1,343
Country 111
Continent 6
TagClass 71

Edge Statistics

Relationship Count
(:Place)-[:IS_PART_OF]->(:Place) 1,454
(:TagClass)-[:IS_SUBCLASS_OF]->(:TagClass) 70
(:Organisation)-[:IS_LOCATED_IN]->(:Place) 7,955
(:Tag)-[:HAS_TYPE]->(:TagClass) 16,080
(:Comment)-[:HAS_CREATOR]->(:Person) 185,495,476
(:Comment)-[:IS_LOCATED_IN]->(:Country) 185,495,476
(:Comment)-[:REPLY_OF]->(:Comment) 85,829,276
(:Comment)-[:REPLY_OF]->(:Post) 99,666,200
(:Forum)-[:CONTAINER_OF]->(:Post) 64,029,217
(:Forum)-[:HAS_MEMBER]->(:Person) 303,838,931
(:Forum)-[:HAS_MODERATOR]->(:Person) 4,611,436
(:Forum)-[:HAS_TAG]->(:Tag) 15,195,472
(:Person)-[:HAS_INTEREST]->(:Tag) 11,057,039
(:Person)-[:IS_LOCATED_IN]->(:City) 473,001
(:Person)-[:KNOWS]->(:Person) 18,655,515
(:Person)-[:LIKES]->(:Comment) 184,325,690
(:Person)-[:LIKES]->(:Post) 98,423,296
(:Post)-[:HAS_CREATOR]->(:Person) 64,029,217
(:Comment)-[:HAS_TAG]->(:Tag) 238,074,593
(:Post)-[:HAS_TAG]->(:Tag) 78,380,259
(:Post)-[:IS_LOCATED_IN]->(:Country) 64,029,217
(:Person)-[:STUDY_AT]->(:University) 378,582
(:Person)-[:WORK_AT]->(:Company) 1,029,492

Total nodes: 254,634,696  ·  Total relationships: 1,703,042,944

Disk space: 201G

Total import time: 3,967 seconds (1h 6m 7s)

Data Loading

Overview

At 254.6 million nodes and 1.71 billion relationships, SF100 is loaded in a single offline pass with neo4j-admin database import full, which writes store files directly, rather than with incremental Cypher statements.

The generated CSV files carry no header row: they are |-delimited data only, and the column definitions live in a parallel tree of header files, one per entity. Each entity is a directory of Spark output shards named part-*.csv, some of which are legitimately empty, so every --nodes and --relationships argument lists the header file first and then all the data shards.

Only initial_snapshot is imported. The inserts and deletes update streams that ship with the dataset are not applied, because this implementation runs the read-only BI queries rather than the full BI workload with batch updates. The result is a fixed snapshot whose latest creationDate is 2012-11-29T02:52:42.849+00:00.

Create Indices

Uniqueness constraints and secondary indices are built during the import by passing this file to --schema, not afterwards. Creating the Message(id) uniqueness constraint after the fact would mean populating an index over 249.5 million nodes as a separate pass.

cypher
// uniqueness constrains (implying an index)
CREATE CONSTRAINT FOR (n:City)         REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Comment)      REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Country)      REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Forum)        REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Message)      REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Organisation) REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Person)       REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Post)         REQUIRE n.id IS UNIQUE;
CREATE CONSTRAINT FOR (n:Tag)          REQUIRE n.id IS UNIQUE;

// name/firstName
CREATE INDEX FOR (n:Country)   ON n.name;
CREATE INDEX FOR (n:Person)    ON n.firstName;
CREATE INDEX FOR (n:Tag)       ON n.name;
CREATE INDEX FOR (n:TagClass)  ON n.name;

// creationDate of nodes
CREATE INDEX FOR (n:Message)   ON n.creationDate;
CREATE INDEX FOR (n:Post)      ON n.creationDate;
CREATE INDEX FOR (n:Forum)     ON n.creationDate;

// creationDate of edges
CREATE INDEX FOR ()-[e:KNOWS]-() ON e.creationDate;

The City and Country constraints and the Country(name) index are included because five queries (Q13, Q18, Q20, Q38 and Q39) look up a country by name and Q11 looks up cities by id.

Import Script

The script below is the whole import, end to end: preflight, dropping the target database, assembling the 8 node groups and 23 relationship groups, running the importer, registering the resulting store as a database, and verifying the counts. The add_nodes and add_rels calls are where the schema mapping lives — Comment and Post both get the Message label there, and Place and Organisation take their concrete label from a :LABEL column in the source CSV.

Two details are easy to get wrong. The database name is a positional argument and must come before --nodes and --relationships, which are declared variadic and would otherwise swallow it as one more data file. And the importer runs as the neo4j user, so its temporary directory and report file have to live somewhere that user can write.

bash
#!/usr/bin/env bash
#
# Bulk-import the LDBC SNB BI SF100 initial snapshot into the Neo4j database "snb100".
#
# Usage:
#   MODE=offline ./import_snb100.sh   # stop Neo4j during the import; the importer gets the
#                                     # whole machine and runs much faster
#   MODE=online  ./import_snb100.sh   # keep the other databases online; the importer runs
#                                     # with a capped memory budget and takes far longer
#   SCHEMA_FILE= ./import_snb100.sh   # skip building indices during the import
#
# Only the target database is ever touched. This host's data directory also holds several
# unrelated benchmark databases and the "system" registry, so the databases directory is
# never cleared as a whole.

set -euo pipefail

# ----- Configuration ---------------------------------------------------------
DB_NAME=snb100
CSV_DIR=/datasets/prop_datasets/ldbc_100/csv
HEADER_DIR=/datasets/prop_datasets/ldbc_snb_bi/neo4j/headers
BOLT=bolt://localhost:7689
OUT_DIR=/datasets/prop_datasets/ldbc_100/scripts
TIMING_FILE=${OUT_DIR}/import-timing.tsv

# The importer runs as the "neo4j" user and cannot write into scripts/ (owned by exouser,
# mode 775). Its own outputs -- temporary files and the bad-entries report -- therefore go
# to a directory it owns on local disk, which is also faster than NFS.
WORK_DIR=/media/volume/sdb/neo4j-import
TEMP_PATH=${WORK_DIR}/tmp
REPORT_FILE=${WORK_DIR}/import-report.txt

# Indices and constraints are built during the import rather than afterwards: populating
# the Message(id) uniqueness constraint over 249.5 million nodes as a separate pass would
SCHEMA_FILE=${SCHEMA_FILE-${OUT_DIR}/indices.cypher}

MODE=${MODE:-online}
if [[ "${MODE}" == "offline" ]]; then
    # The off-heap cap cannot be sized against the whole machine: the importer's JVM heap
    # (10g, from server.memory.heap.max_size in neo4j.conf) sits on top of it, plus roughly
    # 5g for the id mapper and other structures. A 40g cap drove anon-rss to 52.5 GiB during
    # the relationship phase and systemd-oomd killed the process on this 58 GiB host.
    # 24g + 10g heap + overhead is about 40g, leaving 18g of headroom.
    MAX_OFF_HEAP=24g
    THREADS=16
else
    # The running server already holds 10g of heap and 16g of page cache
    MAX_OFF_HEAP=8g
    THREADS=14                                     # leave 2 of 16 cores to the server
fi

# The system default JVM is Java 17; neo4j-admin needs 21. The importer's own heap comes
# from neo4j.conf -- the 2026.05.0 NeoAdminBoot launcher no longer reads HEAP_SIZE -- so
# --max-off-heap-memory below is the only memory knob available here. It covers the page
# cache and the id-mapping structures.
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64

# The safety net that matters for an unattended run: whatever makes this script exit -- an
# OOM during the import, a full disk, a bad argument, a failed preflight -- the Neo4j service
# must come back. On the previous attempt set -e terminated the script at the import and the
# systemctl start at the end never ran, leaving nine other databases offline for an hour.
restore_service () {
    local rc=$?
    if [[ "${MODE}" == "offline" ]] && ! systemctl is-active --quiet neo4j; then
        echo "== Exit code ${rc}: restoring the Neo4j service =="
        sudo systemctl start neo4j || true
    fi
    if [[ ${rc} -ne 0 ]]; then
        echo "== Script exited with code ${rc} (import did not complete) =="
    fi
    return ${rc}
}
trap restore_service EXIT

# ----- Guard rail -------------------------------------------------------------
if [[ -z "${DB_NAME}" || "${DB_NAME}" == "system" || "${DB_NAME}" == "neo4j" ]]; then
    echo "Refusing to run: DB_NAME is empty or points at system/neo4j." >&2
    exit 1
fi

echo "== Configuration =="
echo "  database    ${DB_NAME}"
echo "  mode        ${MODE}"
echo "  off-heap    ${MAX_OFF_HEAP}   threads ${THREADS}"
echo "  CSV         ${CSV_DIR}"
echo "  headers     ${HEADER_DIR}"
echo "  temp dir    ${TEMP_PATH}"
echo "  schema      ${SCHEMA_FILE:-(skipped)}"

# ----- Preflight: all 31 entity directories present and non-empty -------------
echo "== Checking data directories =="
missing=0
for sub in static dynamic; do
    while read -r h; do
        entity=$(basename "${h}" .csv)
        d="${CSV_DIR}/initial_snapshot/${sub}/${entity}"
        if [[ ! -d "${d}" ]]; then
            echo "  missing directory: ${d}" >&2; missing=1
        elif ! find "${d}" -maxdepth 1 -name 'part-*.csv*' -type f -print -quit | grep -q .; then
            echo "  no part files: ${d}" >&2; missing=1
        fi
    done < <(find "${HEADER_DIR}/${sub}" -name '*.csv')
done
[[ ${missing} -eq 0 ]] || { echo "Preflight failed, aborting." >&2; exit 1; }
echo "  all 31 entity directories present"

# ----- Take the target database offline and drop it (repeatable) --------------
# Drop and re-create rather than overwrite in place: dropping also clears the database's
# transaction logs and its system-database entry, so no stale metadata survives.
if cypher-shell -a "${BOLT}" -d system "RETURN 1" >/dev/null 2>&1; then
    echo "== Stopping and dropping ${DB_NAME} (other databases unaffected) =="
    cypher-shell -a "${BOLT}" -d system "STOP DATABASE ${DB_NAME} IF EXISTS WAIT" || true
    cypher-shell -a "${BOLT}" -d system "DROP DATABASE ${DB_NAME} IF EXISTS WAIT"
    cypher-shell -a "${BOLT}" -d system "SHOW DATABASES YIELD name, currentStatus"
else
    echo "== Server not running, skipping STOP/DROP (re-run case) =="
fi

# Clear any temporary files left by a previous run. The cleanup at the end of this script
# does not run if the process is killed (an OOM, for instance), and the leftover temp-<db>
# directory can be tens of gigabytes.
sudo rm -rf "${TEMP_PATH}"
# The directory must be owned by neo4j or the importer cannot write into it
sudo mkdir -p "${TEMP_PATH}"
sudo chown -R neo4j:neo4j "${WORK_DIR}"
if ! sudo -u neo4j touch "${WORK_DIR}/.probe" 2>/dev/null; then
    echo "Preflight failed: the neo4j user cannot write to ${WORK_DIR}" >&2; exit 1
fi
sudo -u neo4j rm -f "${WORK_DIR}/.probe"
echo "  neo4j can write to ${WORK_DIR}"

if [[ -n "${SCHEMA_FILE}" ]] && ! sudo -u neo4j test -r "${SCHEMA_FILE}"; then
    echo "Preflight failed: the neo4j user cannot read ${SCHEMA_FILE}" >&2
    exit 1
fi

if [[ "${MODE}" == "offline" ]]; then
    if systemctl is-active --quiet neo4j; then
        echo "== MODE=offline: stopping Neo4j (data untouched, restarted afterwards) =="
        sudo systemctl stop neo4j
    else
        echo "== Server already stopped =="
    fi
fi

# ----- Assemble the --nodes / --relationships arguments -----------------------
PART='part-*.csv*'
args=()
declare -A GROUP_BYTES=()      # input bytes per group, for throughput afterwards
add_nodes () {   # $1=labels  $2=static|dynamic  $3=entity
    args+=( "--nodes=$1=${HEADER_DIR}/$2/$3.csv$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf ',%p')" )
    GROUP_BYTES["node/$1/$3"]=$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf '%s\n' | awk '{s+=$1} END{print s+0}')
}
add_rels () {    # $1=RELTYPE  $2=static|dynamic  $3=entity
    args+=( "--relationships=$1=${HEADER_DIR}/$2/$3.csv$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf ',%p')" )
    GROUP_BYTES["rel/$1/$3"]=$(find "${CSV_DIR}/initial_snapshot/$2/$3" -type f -name "${PART}" -printf '%s\n' | awk '{s+=$1} END{print s+0}')
}

# Eight node groups. Comment and Post both carry the Message label, which is what lets
# the queries match (:Message) across the two. The Place and Organisation headers end in a
# :LABEL column, so a city arrives as (:Place:City), a university as (:Organisation:University).
add_nodes Place            static  Place
add_nodes Organisation     static  Organisation
add_nodes TagClass         static  TagClass
add_nodes Tag              static  Tag
add_nodes Forum            dynamic Forum
add_nodes Person           dynamic Person
add_nodes Message:Comment  dynamic Comment      # Comment and Post both get :Message
add_nodes Message:Post     dynamic Post

# Twenty-three relationship groups, collapsing into eleven relationship types
add_rels IS_PART_OF      static  Place_isPartOf_Place
add_rels IS_SUBCLASS_OF  static  TagClass_isSubclassOf_TagClass
add_rels IS_LOCATED_IN   static  Organisation_isLocatedIn_Place
add_rels HAS_TYPE        static  Tag_hasType_TagClass
add_rels HAS_CREATOR     dynamic Comment_hasCreator_Person
add_rels IS_LOCATED_IN   dynamic Comment_isLocatedIn_Country
add_rels REPLY_OF        dynamic Comment_replyOf_Comment
add_rels REPLY_OF        dynamic Comment_replyOf_Post
add_rels CONTAINER_OF    dynamic Forum_containerOf_Post
add_rels HAS_MEMBER      dynamic Forum_hasMember_Person
add_rels HAS_MODERATOR   dynamic Forum_hasModerator_Person
add_rels HAS_TAG         dynamic Forum_hasTag_Tag
add_rels HAS_INTEREST    dynamic Person_hasInterest_Tag
add_rels IS_LOCATED_IN   dynamic Person_isLocatedIn_City
add_rels KNOWS           dynamic Person_knows_Person
add_rels LIKES           dynamic Person_likes_Comment
add_rels LIKES           dynamic Person_likes_Post
add_rels HAS_CREATOR     dynamic Post_hasCreator_Person
add_rels HAS_TAG         dynamic Comment_hasTag_Tag
add_rels HAS_TAG         dynamic Post_hasTag_Tag
add_rels IS_LOCATED_IN   dynamic Post_isLocatedIn_Country
add_rels STUDY_AT        dynamic Person_studyAt_University
add_rels WORK_AT         dynamic Person_workAt_Company

echo "== Argument groups: ${#args[@]} (expected 8 nodes + 23 relationships = 31) =="

# Persist the per-group input sizes so throughput can be derived later
{ echo -e "group\tbytes"; for k in "${!GROUP_BYTES[@]}"; do echo -e "${k}\t${GROUP_BYTES[$k]}"; done | sort; } > "${OUT_DIR}/import-input-bytes.tsv"

# ----- Import -----------------------------------------------------------------
# Argument order matters: ${DB_NAME} must precede --nodes/--relationships, which are
# declared variadic (<files>...) and would swallow a trailing database name as one more
# data file, failing with "NoSuchFileException: snb100".
# --id-type=INTEGER  integer ids map far more cheaply than strings over 254.6M nodes
echo "== Starting the import; expect hours (source and target both on NFS) =="
T0=$(date +%s); date
sudo -u neo4j --preserve-env=JAVA_HOME \
    neo4j-admin database import full \
    "${DB_NAME}" \
    --id-type=INTEGER \
    --ignore-empty-strings=true \
    --bad-tolerance=0 \
    --delimiter='|' \
    --array-delimiter=';' \
    --threads="${THREADS}" \
    --max-off-heap-memory="${MAX_OFF_HEAP}" \
    --temp-path="${TEMP_PATH}" \
    --report-file="${REPORT_FILE}" \
    ${SCHEMA_FILE:+--schema="${SCHEMA_FILE}"} \
    --overwrite-destination \
    --verbose \
    "${args[@]}"
T1=$(date +%s); date
echo "== Import finished in $(( (T1-T0)/3600 ))h $(( (T1-T0)%3600/60 ))m $(( (T1-T0)%60 ))s =="
# The report is written by neo4j on local disk; copy it back for convenience
sudo cp "${REPORT_FILE}" "${OUT_DIR}/import-report.txt" 2>/dev/null && sudo chown exouser:exouser "${OUT_DIR}/import-report.txt" || true

echo -e "phase\tseconds" > "${TIMING_FILE}"
echo -e "total_wall_clock\t$((T1-T0))" >> "${TIMING_FILE}"

if [[ "${MODE}" == "offline" ]]; then
    echo "== Restarting Neo4j =="
    sudo systemctl start neo4j
    for i in $(seq 1 120); do
        cypher-shell -a "${BOLT}" -d system "RETURN 1" >/dev/null 2>&1 && break
        sleep 5
    done
fi

# ----- Register the store as a database and bring it online -------------------
echo "== Creating and starting ${DB_NAME} =="
T2=$(date +%s)
cypher-shell -a "${BOLT}" -d system "CREATE DATABASE ${DB_NAME} WAIT"
T3=$(date +%s)
echo -e "create_database_online\t$((T3-T2))" >> "${TIMING_FILE}"
cypher-shell -a "${BOLT}" -d system "SHOW DATABASES YIELD name, currentStatus"

# ----- Verify -----------------------------------------------------------------
echo "== Node count per label =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" \
    "MATCH (n) UNWIND labels(n) AS l RETURN l AS label, count(*) AS cnt ORDER BY cnt DESC" \
    | tee "${OUT_DIR}/count-nodes.txt"

echo "== Relationship count per type =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" \
    "MATCH ()-[r]->() RETURN type(r) AS relType, count(*) AS cnt ORDER BY cnt DESC" \
    | tee "${OUT_DIR}/count-rels.txt"

echo "== Index and constraint state =="
cypher-shell -a "${BOLT}" -d "${DB_NAME}" "SHOW INDEXES YIELD name, state, owningConstraint"

sudo rm -rf "${TEMP_PATH}" 2>/dev/null || true
echo
echo "Done. Output files:"
echo "  ${OUT_DIR}/import-report.txt        importer bad-entries report"
echo "  ${TIMING_FILE}                  timings"
echo "  ${OUT_DIR}/import-input-bytes.tsv  input bytes per group"
echo "  ${OUT_DIR}/count-nodes.txt         node count per label"
echo "  ${OUT_DIR}/count-rels.txt          relationship count per type"

Queries

Queries are numbered in workload order, so the numbering does not match the LDBC BI query numbers. Every query states its origin under its description. Because the BI queries are shown exactly as the LDBC reference implementation ships them, their first comment line still carries the LDBC number — Q11 below, for instance, opens with // Q19. because it is bi-19.cypher.

20 of the 41 come from the LDBC SNB Business Intelligence workload. Their Cypher is the LDBC reference implementation unmodified, and each names the bi-N.cypher file it came from. Their parameters come from the official parameters-sf100 set; queries whose parameter set has a and b variants are run once per variant against the same Cypher.

The other 21 were written for this benchmark and are not part of the LDBC BI workload. The BI workload exercises only 7 of the 28 PropGBench query types, so these fill the remaining ones: each adapts the GQL template given on that type’s workload-description page to the SNB schema, and each was run against the SF100 instance.

These queries have no official parameter files. The values used to validate them on SF100 were personId = 100316, person1Id = 10006 with person2Id = 2199023261649 (a connected pair), language = 'en' or 'es', tagA = 'Thomas_More' with tagB = 'Michelangelo', and date ranges inside 2010–2012.

The PropGBench query types are single primitives — a degree statistic, a triangle, a shortest path — whereas an SNB BI query is a complete analytical question that usually composes several of them. 1 query sits under Hybrid because it falls squarely on two defined types at once, and 9 sit under Others because their distinguishing work — summarising a node property, comparing two time windows, deriving a subpopulation and then measuring within it — matches no single defined type. Each of those queries states in its description why it does not fit.

Select a query below to view its logic and description.

1. Neighborhood
2. Path
3. Pattern
4. Aggregation
5. Analytics
6. Hybrid
7. Others

Q1. One-hop friends of a given person

Subcategory: T1.1 One-Hop Neighbors

Return the direct KNOWS neighbours of one person. This is the baseline local traversal: a single indexed node lookup followed by one adjacency expansion.

Source: written for PropGBench, adapting the T1.1 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T1.1. One-hop friends of a given person
MATCH (:Person {id: $personId})-[:KNOWS]-(friend:Person)
RETURN friend.id AS personId, friend.firstName AS firstName, friend.lastName AS lastName
ORDER BY personId
LIMIT $limit

Q2. Persons within three friendship hops

Subcategory: T1.2 k-Hop Neighborhood

Return every distinct person reachable within one to three KNOWS hops of a start person. The frontier grows quickly on a social graph, so this measures expansion cost and duplicate elimination across overlapping paths.

Source: written for PropGBench, adapting the T1.2 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T1.2. Persons within three friendship hops of a given person
MATCH (start:Person {id: $personId})-[:KNOWS*1..3]-(neighbor:Person)
WHERE neighbor <> start
RETURN DISTINCT neighbor.id AS personId, neighbor.firstName AS firstName, neighbor.lastName AS lastName
ORDER BY personId
LIMIT $limit

Q3. Persons exactly three friendship hops away

Subcategory: T1.3 Exact kth-Hop Neighbors

Return the persons reached by paths of length exactly three, rather than everything within three hops. Only the start person is excluded, so a person who is also reachable in fewer hops still appears if a three-hop path to them exists.

Source: written for PropGBench, adapting the T1.3 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T1.3. Persons exactly three friendship hops away
MATCH (start:Person {id: $personId})-[:KNOWS*3]-(neighbor:Person)
WHERE neighbor <> start
RETURN DISTINCT neighbor.id AS personId, neighbor.firstName AS firstName, neighbor.lastName AS lastName
ORDER BY personId
LIMIT $limit

Q4. Persons within two hops speaking a given language

Subcategory: T1.4 Neighborhood with Property Filter

Expand one to two KNOWS hops and keep only the persons who speak a given language and were born on or after a given date. The speaks property is an array, so the filter is a list membership test. It measures whether the planner pushes the predicate into the expansion or filters the fully expanded frontier afterwards.

Source: written for PropGBench, adapting the T1.4 template from the workload description.

Parameters: personId: ID  ·  language: STRING  ·  birthday: DATE  ·  limit: INT

cypher
// T1.4. Persons within two friendship hops who speak a given language
MATCH (start:Person {id: $personId})-[:KNOWS*1..2]-(neighbor:Person)
WHERE neighbor <> start
  AND $language IN neighbor.speaks
  AND neighbor.birthday >= $birthday
RETURN DISTINCT neighbor.id AS personId,
       neighbor.firstName AS firstName,
       neighbor.lastName AS lastName,
       neighbor.birthday AS birthday
ORDER BY birthday DESC, personId
LIMIT $limit

Q5. Persons speaking a given language who registered in a period

Subcategory: T2.1 Node Filtering (Single Node Paths)

Filter Person nodes on an array property and a creationDate range with no traversal at all. This is the single-node path case, measuring predicate evaluation and label-scan or index-lookup performance in isolation.

Source: written for PropGBench, adapting the T2.1 template from the workload description.

Parameters: language: STRING  ·  startDate: DATETIME  ·  endDate: DATETIME  ·  limit: INT

cypher
// T2.1. Persons who speak a given language and registered in a given period
MATCH (person:Person)
WHERE $language IN person.speaks
  AND person.creationDate >= $startDate
  AND person.creationDate <= $endDate
RETURN person.id AS personId,
       person.firstName AS firstName,
       person.lastName AS lastName,
       person.creationDate AS creationDate
ORDER BY creationDate DESC, personId
LIMIT $limit

Q6. Friendship edges created within a given window

Subcategory: T2.2 Edge Filtering

Filter KNOWS relationships by their creationDate and return both endpoints. A relationship-property index on KNOWS(creationDate) is created during the import, so this exercises relationship index lookup rather than a full relationship scan.

Source: written for PropGBench, adapting the T2.2 template from the workload description.

Parameters: startDate: DATETIME  ·  endDate: DATETIME  ·  limit: INT

cypher
// T2.2. Friendship edges created within a given window
MATCH (person1:Person)-[knows:KNOWS]->(person2:Person)
WHERE knows.creationDate >= $startDate
  AND knows.creationDate <= $endDate
RETURN person1.id AS person1Id,
       person2.id AS person2Id,
       knows.creationDate AS creationDate
ORDER BY creationDate, person1Id
LIMIT $limit

Q7. Whether two persons are connected within four friendship hops

Subcategory: T2.3 Reachability between Two Nodes

Return a single boolean saying whether a bounded KNOWS path exists between two persons. The EXISTS subquery lets the engine stop at the first matching path, so this measures short-circuiting rather than full path enumeration.

Source: written for PropGBench, adapting the T2.3 template from the workload description.

Parameters: person1Id: ID  ·  person2Id: ID

cypher
// T2.3. Whether two persons are connected within four friendship hops
MATCH (source:Person {id: $person1Id}),
      (target:Person {id: $person2Id})
RETURN EXISTS {
  MATCH (source)-[:KNOWS*1..4]-(target)
} AS reachable

Q8. Shortest friendship path between two persons

Subcategory: T2.4 Shortest Path

Return the shortest KNOWS path between two persons, bounded at six hops, as the list of person ids along it. Neo4j’s shortestPath runs a bidirectional search and stops at the first shortest match, so unlike the simple-path enumeration it prunes aggressively. The weighted variants of this problem are Q10, Q11 and Q12.

Source: written for PropGBench, adapting the T2.4 template from the workload description.

Parameters: person1Id: ID  ·  person2Id: ID

cypher
// T2.4. Shortest friendship path between two persons
MATCH p = shortestPath(
  (:Person {id: $person1Id})-[:KNOWS*1..6]-(:Person {id: $person2Id})
)
RETURN [n IN nodes(p) | n.id] AS personIds, length(p) AS hopCount

Q9. All simple friendship paths between two persons up to length three

Subcategory: T2.5 All Simple Paths up to Length k

Enumerate every path between two persons up to three hops, keeping only simple paths. Cypher’s variable-length matching already forbids repeating a relationship but permits revisiting a node, so the all(... single(...)) predicate removes the paths that do. Unlike reachability or shortest path this materialises every result, so it is the most resource-intensive of the path queries.

Source: written for PropGBench, adapting the T2.5 template from the workload description.

Parameters: person1Id: ID  ·  person2Id: ID  ·  limit: INT

cypher
// T2.5. All simple friendship paths between two persons up to length three
MATCH p = (:Person {id: $person1Id})-[:KNOWS*1..3]-(:Person {id: $person2Id})
WHERE all(i IN range(0, size(nodes(p)) - 1)
          WHERE single(n IN nodes(p) WHERE n = nodes(p)[i]))
RETURN [n IN nodes(p) | n.id] AS personIds, length(p) AS hopCount
ORDER BY hopCount ASC
LIMIT $limit

Q10. Weighted interaction paths

Subcategory: T2.6 top-K Weighted Paths (Optional)

Find the most trusted path between two persons. Edge weight is derived from how strongly the two interacted inside forums created in a given window (a reply counts 1.0 or 0.5, and the weight is 1/(w+1)), then Dijkstra is run over that weighting. The weights are not stored on the edges but computed into a GDS projection, which is what makes a weighted-path query meaningful on an otherwise unweighted social graph. The query drops and rebuilds its own projection inline, so it needs no separate precomputation step.

Source: LDBC SNB BI workload, bi-15.cypher, used unmodified.

Also exercises: T2.4 Shortest Path

Parameters: person1Id: ID  ·  person2Id: ID  ·  startDate: DATE  ·  endDate: DATE

cypher
// Q15. Weighted interaction paths
// Requires the Neo4j Graph Data Science library
CALL gds.graph.drop('bi15', false)
YIELD graphName

// ----------------------------------------------------------------------------------------------------
WITH count(*) AS dummy
// ----------------------------------------------------------------------------------------------------

CALL gds.graph.project.cypher(
  'bi15',
  'MATCH (p:Person) RETURN id(p) AS id',
  'MATCH (pA:Person)-[knows:KNOWS]-(pB:Person)
      OPTIONAL MATCH (pA)<-[:HAS_CREATOR]-(m1:Message)-[r:REPLY_OF]-(m2:Message)-[:HAS_CREATOR]->(pB)
      OPTIONAL MATCH (m1)-[:REPLY_OF*0..]->(:Post)<-[:CONTAINER_OF]-(forum:Forum)
              WHERE forum.creationDate >= datetime({epochmillis: ' + $startDate.epochMillis + '})
                AND forum.creationDate <= datetime({epochmillis: ' + $endDate.epochMillis   + '})
      WITH pA, pB,
          sum(CASE forum IS NOT NULL
              WHEN true THEN
                  CASE (m1:Post OR m2:Post) WHEN true THEN 1.0
                  ELSE 0.5 END
              ELSE 0.0 END
          ) AS w
      RETURN
        id(pA) AS source,
        id(pB) AS target,
        1/(w+1) AS weight
  '
)
YIELD graphName

// ----------------------------------------------------------------------------------------------------
WITH count(*) AS dummy
// ----------------------------------------------------------------------------------------------------

CALL {
  MATCH (person1:Person {id: $person1Id}), (person2:Person {id: $person2Id})
  CALL gds.shortestPath.dijkstra.stream('bi15', {
    sourceNode: person1,
    targetNode: person2,
    relationshipWeightProperty: 'weight'
  })
  YIELD index, sourceNode, targetNode, totalCost, nodeIds, costs, path
  RETURN totalCost
  UNION ALL
  RETURN -1.0 AS totalCost
}
RETURN max(totalCost) AS totalCost
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q11. Interaction path between cities

Subcategory: T2.6 top-K Weighted Paths (Optional)

Find the cheapest weighted path between persons living in two given cities. Edge weight falls as reply interaction rises (round(40-sqrt(numInteractions)), floored at 1), so heavily interacting pairs sit closer together. The projection carrying those weights must be built beforehand with bi-19-create-graph.cypher.

Source: LDBC SNB BI workload, bi-19.cypher, used unmodified.

Also exercises: T2.4 Shortest Path

Parameters: city1Id: ID  ·  city2Id: ID

cypher
// Q19. Interaction path between cities
// Requires the Neo4j Graph Data Science library
MATCH
  (person1:Person)-[:IS_LOCATED_IN]->(city1:City {id: $city1Id}),
  (person2:Person)-[:IS_LOCATED_IN]->(city2:City {id: $city2Id})
CALL gds.shortestPath.dijkstra.stream('bi19', {
  sourceNode: person1,
  targetNode: person2,
  relationshipWeightProperty: 'weight'
})
YIELD totalCost
WITH person1.id AS person1Id, person2.id AS person2Id, totalCost AS totalWeight
ORDER BY totalWeight ASC, person1.id ASC, person2.id ASC
WITH collect({person1Id: person1Id, person2Id: person2Id, totalWeight: totalWeight}) AS results
UNWIND results AS result
WITH result.person1Id AS person1Id, result.person2Id AS person2Id, result.totalWeight AS totalWeight
WHERE totalWeight = results[0].totalWeight
RETURN person1Id, person2Id, totalWeight
ORDER BY person1Id, person2Id
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Projection built before the query runs (bi-19-create-graph.cypher):

cypher
CALL gds.graph.project.cypher(
    'bi19',
    'MATCH (p:Person) RETURN id(p) AS id',
    'MATCH
      (personA:Person)-[:KNOWS]-(personB:Person),
      (personA)<-[:HAS_CREATOR]-(:Message)-[replyOf:REPLY_OF]-(:Message)-[:HAS_CREATOR]->(personB)
    WITH
      id(personA) AS source,
      id(personB) AS target,
      count(replyOf) AS numInteractions
    RETURN
      source,
      target,
      CASE WHEN round(40-sqrt(numInteractions)) > 1 THEN round(40-sqrt(numInteractions)) ELSE 1 END AS weight'
)

Released afterwards (bi-19-drop-graph.cypher):

cypher
CALL gds.graph.drop('bi19', false)

A variant that builds the weights inline instead of relying on a precomputed projection is also provided as bi-19-without-precomputation.cypher.

Q12. Recruitment

Subcategory: T2.6 top-K Weighted Paths (Optional)

From every current employee of a target company, find the cheapest weighted path to a given candidate. Edge weight is the smallest difference in enrolment year between two persons who studied at the same university (min(abs(classYear difference)) + 1). The projection must be built beforehand with bi-20-create-graph.cypher.

Source: LDBC SNB BI workload, bi-20.cypher, used unmodified.

Also exercises: T2.4 Shortest Path

Parameters: company: STRING  ·  person2Id: ID

cypher
// Q20. Recruitment
// Requires the Neo4j Graph Data Science library
MATCH
  (company:Company {name: $company})<-[:WORK_AT]-(person1:Person),
  (person2:Person {id: $person2Id})
CALL gds.shortestPath.dijkstra.stream('bi20', {
  sourceNode: person1,
  targetNode: person2,
  relationshipWeightProperty: 'weight'
})
YIELD totalCost
WHERE person1.id <> $person2Id
WITH person1.id AS person1Id, totalCost AS totalWeight
ORDER BY totalWeight ASC
WITH collect({person1Id: person1Id, totalWeight: totalWeight}) AS results
UNWIND results AS result
WITH result.person1Id AS person1Id, result.totalWeight AS totalWeight
WHERE totalWeight = results[0].totalWeight
RETURN person1Id, totalWeight
ORDER BY person1Id ASC
LIMIT 20
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Projection built before the query runs (bi-20-create-graph.cypher):

cypher
CALL gds.graph.project.cypher(
    'bi20',
    'MATCH (p:Person) RETURN id(p) AS id',
    'MATCH
      (personA:Person)-[:KNOWS]-(personB:Person),
      (personA)-[saA:STUDY_AT]->(u:University)<-[saB:STUDY_AT]-(personB)
    RETURN
      id(personA) AS source,
      id(personB) AS target,
      min(abs(saA.classYear - saB.classYear)) + 1 AS weight'
)

Released afterwards (bi-20-drop-graph.cypher):

cypher
CALL gds.graph.drop('bi20', false)

A variant that builds the weights inline instead of relying on a precomputed projection is also provided as bi-20-without-precomputation.cypher.

Q13. Friend triangles

Subcategory: T3.1 Triangle Detection

Count friendship triangles inside one country where all three KNOWS edges were created within a given window. The a.id < b.id < c.id ordering deduplicates, so each triangle is counted once rather than six times.

Source: LDBC SNB BI workload, bi-11.cypher, used unmodified.

Parameters: country: STRING  ·  startDate: DATE  ·  endDate: DATE

cypher
// Q11. Friend triangles
MATCH (a:Person)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country:Country {name: $country}),
      (a)-[k1:KNOWS]-(b:Person)
WHERE a.id < b.id
  AND $startDate <= k1.creationDate AND k1.creationDate <= $endDate
WITH DISTINCT country, a, b
MATCH (b)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country)
WITH DISTINCT country, a, b
MATCH (b)-[k2:KNOWS]-(c:Person),
      (c)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(country)
WHERE b.id < c.id
  AND $startDate <= k2.creationDate AND k2.creationDate <= $endDate
WITH DISTINCT a, b, c
MATCH (c)-[k3:KNOWS]-(a)
WHERE $startDate <= k3.creationDate AND k3.creationDate <= $endDate
WITH DISTINCT a, b, c
RETURN count(*) AS count

Q14. Friendship squares around a given person

Subcategory: T3.2 Square Detection

Find four-node cycles through a given person: the person, two of their friends, and a fourth person who knows both. All six inequality constraints are stated explicitly so that degenerate matches collapsing two roles onto one person are excluded.

Source: written for PropGBench, adapting the T3.2 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T3.2. Friendship squares around a given person
MATCH (a:Person {id: $personId})-[:KNOWS]-(b:Person),
      (b)-[:KNOWS]-(c:Person),
      (c)-[:KNOWS]-(d:Person),
      (d)-[:KNOWS]-(a)
WHERE a <> b AND a <> c AND a <> d
  AND b <> c AND b <> d AND c <> d
RETURN DISTINCT b.id AS firstFriendId, c.id AS bridgeFriendId, d.id AS secondFriendId
LIMIT $limit

Q15. Forums with the most members among those created after a date

Subcategory: T3.3 Star Pattern Query

Find hub forums: those created after a given date whose membership exceeds a threshold, ranked by member count. HAS_MEMBER is the largest relationship type in the graph at 303.8 million edges and forum membership is heavily skewed, so this is the star pattern under a realistic degree distribution.

Source: written for PropGBench, adapting the T3.3 template from the workload description.

Parameters: date: DATETIME  ·  minDegree: INT  ·  limit: INT

cypher
// T3.3. Forums with the most members among those created after a given date
MATCH (forum:Forum)-[:HAS_MEMBER]->(person:Person)
WHERE forum.creationDate > $date
WITH forum, count(DISTINCT person) AS memberCount
WHERE memberCount >= $minDegree
RETURN forum.id AS forumId, forum.title AS title, memberCount
ORDER BY memberCount DESC, forumId
LIMIT $limit

Q16. Groups of four mutually acquainted persons around a person

Subcategory: T3.4 4-Clique

Find 4-cliques containing a given person, where all six pairs among the four are connected by KNOWS. The b.id < c.id < d.id ordering reports each clique once instead of six times. It is the densest pattern in the set and the most demanding on join ordering.

Source: written for PropGBench, adapting the T3.4 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T3.4. Groups of four mutually acquainted persons around a given person
MATCH (a:Person {id: $personId})-[:KNOWS]-(b:Person),
      (a)-[:KNOWS]-(c:Person),
      (a)-[:KNOWS]-(d:Person),
      (b)-[:KNOWS]-(c),
      (b)-[:KNOWS]-(d),
      (c)-[:KNOWS]-(d)
WHERE b.id < c.id AND c.id < d.id
RETURN b.id AS person2Id, c.id AS person3Id, d.id AS person4Id
LIMIT $limit

Q17. Friend recommendation

Subcategory: T3.5 Two-hop Join Pattern

For two persons who share an interest in a tag but are not yet connected by KNOWS, count their mutual friends and rank by that count. This is the common-neighbour join T3.5 describes, combined with a negative pattern (NOT (person1)-[:KNOWS]-(person2)).

Source: LDBC SNB BI workload, bi-18.cypher, used unmodified.

Parameters: tag: STRING

cypher
// Q18. Friend recommendation
MATCH (tag:Tag {name: $tag})<-[:HAS_INTEREST]-(person1:Person)-[:KNOWS]-(mutualFriend:Person)-[:KNOWS]-(person2:Person)-[:HAS_INTEREST]->(tag)
WHERE person1 <> person2
  AND NOT (person1)-[:KNOWS]-(person2)
RETURN person1.id AS person1Id, person2.id AS person2Id, count(DISTINCT mutualFriend) AS mutualFriendCount
ORDER BY mutualFriendCount DESC, person1Id ASC, person2Id ASC
LIMIT 20

Q18. Popular topics in a country

Subcategory: T3.6 Line Pattern

A long chain running Country → City → Person → Forum → Post → Message → Tag → TagClass, with a variable-length REPLY_OF*0.. segment walking the reply tree in the middle, counting the messages per forum that hit a given TagClass.

Source: LDBC SNB BI workload, bi-3.cypher, used unmodified.

Parameters: tagClass: STRING  ·  country: STRING

cypher
// Q3. Popular topics in a country
MATCH
  (:Country {name: $country})<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-
  (person:Person)<-[:HAS_MODERATOR]-(forum:Forum)-[:CONTAINER_OF]->
  (post:Post)<-[:REPLY_OF*0..]-(message:Message)-[:HAS_TAG]->(:Tag)-[:HAS_TYPE]->(:TagClass {name: $tagClass})
RETURN
  forum.id,
  forum.title,
  forum.creationDate,
  person.id,
  count(DISTINCT message) AS messageCount
ORDER BY
  messageCount DESC,
  forum.id ASC
LIMIT 20

Q19. Related Topics

Subcategory: T3.6 Line Pattern

Follow a three-hop line from a tag through its messages and their replies to the other tags those replies carry, excluding replies that are themselves tagged with the starting tag. The exclusion is a negative pattern over the same three-hop shape.

Source: LDBC SNB BI workload, bi-7.cypher, used unmodified.

Parameters: tag: STRING

cypher
// Q7. Related Topics
MATCH
  (tag:Tag {name: $tag})<-[:HAS_TAG]-(message:Message),
  (message)<-[:REPLY_OF]-(comment:Comment)-[:HAS_TAG]->(relatedTag:Tag)
WHERE NOT (comment)-[:HAS_TAG]->(tag)
RETURN
  relatedTag.name,
  count(DISTINCT comment) AS count
ORDER BY
  count DESC,
  relatedTag.name ASC
LIMIT 100

Q20. International dialog

Subcategory: T3.6 Line Pattern

For friend pairs split across two countries, score their interaction: a reply scores 4 or 1 and a like scores 10 or 1. Each of the four OPTIONAL MATCH branches is a line running person1 → message → message → person2 or person1 → message → person2, so the pair is connected through chained multi-hop relations rather than a single shared neighbour. Only the highest-scoring pair per city is returned.

Source: LDBC SNB BI workload, bi-14.cypher, used unmodified.

Also exercises: T3.5 Two-hop Join Pattern

Parameters: country1: STRING  ·  country2: STRING

cypher
// Q14. International dialog
MATCH
  (country1:Country {name: $country1})<-[:IS_PART_OF]-(city1:City)<-[:IS_LOCATED_IN]-(person1:Person),
  (country2:Country {name: $country2})<-[:IS_PART_OF]-(city2:City)<-[:IS_LOCATED_IN]-(person2:Person),
  (person1)-[:KNOWS]-(person2)
WITH person1, person2, city1, 0 AS score
// case 1
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(c:Comment)-[:REPLY_OF]->(:Message)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN c IS NULL THEN 0 ELSE  4 END) AS score
// case 2
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(m:Message)<-[:REPLY_OF]-(:Comment)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE  1 END) AS score
// case 3
OPTIONAL MATCH (person1)-[:LIKES]->(m:Message)-[:HAS_CREATOR]->(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE 10 END) AS score
// case 4
OPTIONAL MATCH (person1)<-[:HAS_CREATOR]-(m:Message)<-[:LIKES]-(person2)
WITH DISTINCT person1, person2, city1, score + (CASE WHEN m IS NULL THEN 0 ELSE  1 END) AS score
// preorder
ORDER BY
  city1.name ASC,
  score DESC,
  person1.id ASC,
  person2.id ASC
WITH city1, collect({score: score, person1Id: person1.id, person2Id: person2.id})[0] AS top
RETURN
  top.person1Id,
  top.person2Id,
  city1.name,
  top.score
ORDER BY
  top.score DESC,
  top.person1Id ASC,
  top.person2Id ASC
LIMIT 100

Q21. Most active Posters of a given Topic

Subcategory: T4.2 Grouped Node Degree Statistics by Label

For each person posting about a tag, aggregate three per-node degrees — messages created, replies received and likes received — with two OPTIONAL MATCH clauses collecting the latter two, then group by person and rank by 1×messages + 2×replies + 10×likes.

Source: LDBC SNB BI workload, bi-5.cypher, used unmodified.

Also exercises: T5.1 Degree Centrality

Parameters: tag: STRING

cypher
// Q5. Most active Posters of a given Topic
MATCH (tag:Tag {name: $tag})<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person:Person)
OPTIONAL MATCH (message)<-[likes:LIKES]-(:Person)
WITH person, message, count(likes) AS likeCount
OPTIONAL MATCH (message)<-[:REPLY_OF]-(reply:Comment)
WITH person, message, likeCount, count(reply) AS replyCount
WITH person, count(message) AS messageCount, sum(likeCount) AS likeCount, sum(replyCount) AS replyCount
RETURN
  person.id,
  replyCount,
  likeCount,
  messageCount,
  1*messageCount + 2*replyCount + 10*likeCount AS score
ORDER BY
  score DESC,
  person.id ASC
LIMIT 100

Q22. How many persons have a given number of posts

Subcategory: T4.2 Grouped Node Degree Statistics by Label

Compute the distribution of how many persons created N qualifying messages, where a message must have content, be shorter than a threshold, be newer than a start date, and belong to a post in one of the given languages. This is a per-person degree histogram over HAS_CREATOR.

Source: LDBC SNB BI workload, bi-12.cypher, used unmodified.

Parameters: startDate: DATE  ·  lengthThreshold: INT  ·  languages: STRING[]

cypher
// Q12. How many persons have a given number of posts
MATCH (person:Person)
OPTIONAL MATCH (person)<-[:HAS_CREATOR]-(message:Message)-[:REPLY_OF*0..]->(post:Post)
WHERE message.content IS NOT NULL
  AND message.length < $lengthThreshold
  AND message.creationDate > $startDate
  AND post.language IN $languages
WITH
  person,
  count(message) AS messageCount
RETURN
  messageCount,
  count(person) AS personCount
ORDER BY
  personCount DESC,
  messageCount DESC

Q23. Enrolment-year statistics over all study relationships

Subcategory: T4.3 Ungrouped Edge Count and Property Statistics (Optional)

Count every STUDY_AT relationship and summarise its numeric classYear property with a global average, minimum and maximum. No grouping and no node properties are involved, so it isolates relationship scanning and aggregation.

Source: written for PropGBench, adapting the T4.3 template from the workload description.

Parameters:

cypher
// T4.3. Enrolment-year statistics over all study relationships
MATCH (:Person)-[studyAt:STUDY_AT]->(:University)
RETURN count(studyAt) AS edgeCount,
       avg(studyAt.classYear) AS avgClassYear,
       min(studyAt.classYear) AS minClassYear,
       max(studyAt.classYear) AS maxClassYear

Q24. Enrolment-year statistics by university and gender

Subcategory: T4.4 Grouped Edge Count and Property Statistics (Optional)

The grouped counterpart of the previous query: the same classYear statistics, grouped by a property of the target node (the university name) and one of the source node (the person’s gender), ordered by edge count. It measures multidimensional grouping and top-K materialisation.

Source: written for PropGBench, adapting the T4.4 template from the workload description.

Parameters: limit: INT

cypher
// T4.4. Enrolment-year statistics grouped by university and gender
MATCH (person:Person)-[studyAt:STUDY_AT]->(university:University)
RETURN university.name AS universityName,
       person.gender AS gender,
       count(studyAt) AS edgeCount,
       avg(studyAt.classYear) AS avgClassYear,
       min(studyAt.classYear) AS minClassYear,
       max(studyAt.classYear) AS maxClassYear
ORDER BY edgeCount DESC, universityName, gender
LIMIT $limit

Q25. Friendship path counts by length and gender reached

Subcategory: T4.6 Grouped Path Counts by Length and Endpoint Type

Enumerate KNOWS paths up to three hops from one person and report, for each combination of path length and the gender of the person reached, how many paths there are and how many distinct persons they reach. Grouping by an endpoint property on top of path length is what separates this from a plain path-count query.

Source: written for PropGBench, adapting the T4.6 template from the workload description.

Parameters: personId: ID  ·  limit: INT

cypher
// T4.6. Friendship path counts by length and by the gender reached
MATCH p = (:Person {id: $personId})-[:KNOWS*1..3]-(neighbor:Person)
RETURN length(p) AS pathLength,
       neighbor.gender AS gender,
       count(p) AS pathCount,
       count(DISTINCT neighbor) AS distinctPersonCount
ORDER BY pathLength ASC, pathCount DESC
LIMIT $limit

Q26. Persons interested in both of two given tags

Subcategory: T4.7 Union and Intersection

Return the persons who have a HAS_INTEREST edge to each of two named tags. Two MATCH clauses over the same variable express the intersection; replacing the second with an OPTIONAL MATCH would give the union instead.

Source: written for PropGBench, adapting the T4.7 template from the workload description.

Parameters: tagA: STRING  ·  tagB: STRING  ·  limit: INT

cypher
// T4.7. Persons interested in both of two given tags
MATCH (person:Person)-[:HAS_INTEREST]->(:Tag {name: $tagA})
MATCH (person)-[:HAS_INTEREST]->(:Tag {name: $tagB})
RETURN DISTINCT person.id AS personId,
       person.firstName AS firstName,
       person.lastName AS lastName
ORDER BY personId
LIMIT $limit

Q27. Degree centrality over the friendship graph

Subcategory: T5.1 Degree Centrality

Rank persons by how many friends they have on the undirected KNOWS graph. It is the cheapest of the analytics queries — a single pass over the adjacency structure with no iteration — and gives a baseline against which PageRank’s cost can be read.

Source: written for PropGBench, adapting the T5.1 template from the workload description.

Parameters:

The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.

cypher
CALL gds.graph.project(
  'KnowsGraph',
  'Person',
  {
    KNOWS: {
      type: 'KNOWS',
      orientation: 'UNDIRECTED'
    }
  }
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;

Then run the following analytic query.

cypher
CALL gds.degree.stream('KnowsGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS person, score
RETURN person.id AS personId,
       person.firstName AS firstName,
       person.lastName AS lastName,
       toInteger(score) AS degree
ORDER BY degree DESC
LIMIT 10;
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q28. PageRank over the friendship graph

Subcategory: T5.2 PageRank

Rank persons by PageRank on the undirected KNOWS graph to find the most structurally influential accounts. The algorithm iterates until the scores converge or the iteration limit is reached.

Source: written for PropGBench, adapting the T5.2 template from the workload description.

Parameters:

The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.

cypher
CALL gds.graph.project(
  'KnowsGraph',
  'Person',
  {
    KNOWS: {
      type: 'KNOWS',
      orientation: 'UNDIRECTED'
    }
  }
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;

Then run the following analytic query.

cypher
CALL gds.pageRank.stream('KnowsGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS person, score
RETURN person.id AS personId,
       person.firstName AS firstName,
       person.lastName AS lastName,
       score AS pagerank
ORDER BY pagerank DESC
LIMIT 10;
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q29. Weakly connected components of the friendship graph

Subcategory: T5.3 Connected Components / Weakly Connected Components

Find the connected regions of the undirected KNOWS graph and report the largest by size. On a generated social network almost every person falls in one giant component, so the result also shows how many isolated accounts the generator produced.

Source: written for PropGBench, adapting the T5.3 template from the workload description.

Parameters:

The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.

cypher
CALL gds.graph.project(
  'KnowsGraph',
  'Person',
  {
    KNOWS: {
      type: 'KNOWS',
      orientation: 'UNDIRECTED'
    }
  }
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;

Then run the following analytic query.

cypher
CALL gds.wcc.stream('KnowsGraph')
YIELD nodeId, componentId
RETURN componentId, count(*) AS componentSize
ORDER BY componentSize DESC
LIMIT 10;
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q30. Strongly connected components of the directed friendship graph

Subcategory: T5.4 Strongly Connected Components

Find the maximal sets of persons that can all reach each other following KNOWS edges in their stored direction. This needs a separate NATURAL projection rather than the undirected one. Note that SNB stores each friendship once, as a single directed edge, so mutual reachability is rare and the components are expected to be mostly singletons.

Source: written for PropGBench, adapting the T5.4 template from the workload description.

Parameters:

The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.

cypher
CALL gds.graph.project(
  'KnowsDirectedGraph',
  'Person',
  {
    KNOWS: {
      type: 'KNOWS',
      orientation: 'NATURAL'
    }
  }
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;

Then run the following analytic query.

cypher
CALL gds.scc.stream('KnowsDirectedGraph')
YIELD nodeId, componentId
RETURN componentId, count(*) AS componentSize
ORDER BY componentSize DESC
LIMIT 10;
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q31. Louvain communities of the friendship graph

Subcategory: T5.5 Community Detection

Partition the undirected KNOWS graph into densely connected communities with Louvain and report the largest. The generator correlates friendship with location and interests, so the communities it finds are not arbitrary.

Source: written for PropGBench, adapting the T5.5 template from the workload description.

Parameters:

The queries in the Analytics category require an in-memory GDS graph to be projected first. Run the query below to do it.

cypher
CALL gds.graph.project(
  'KnowsGraph',
  'Person',
  {
    KNOWS: {
      type: 'KNOWS',
      orientation: 'UNDIRECTED'
    }
  }
)
YIELD graphName, nodeCount, relationshipCount, projectMillis
RETURN graphName, nodeCount, relationshipCount, projectMillis;

Then run the following analytic query.

cypher
CALL gds.louvain.stream('KnowsGraph')
YIELD nodeId, communityId
RETURN communityId, count(*) AS communitySize
ORDER BY communitySize DESC
LIMIT 10;
This query requires the Neo4j Graph Data Science library. It runs against a named in-memory projection in .stream mode, so nothing is written back to the database.

Q32. Top thread initiators

Subcategory: T4.1 Ungrouped Node Degree Statistics by Label and T4.5 Path Counts by Length

Count the posts each person started within a window, together with the messages in the whole reply tree beneath them (REPLY_OF*0..) that also fall in the window. It combines two workloads: the bounded-length path enumeration of T4.5, and the ungrouped per-node count of T4.1 applied to the thread initiator, since the result is a count of distinct posts and distinct replies per person rather than a breakdown by path length.

Source: LDBC SNB BI workload, bi-9.cypher, used unmodified.

Parameters: startDate: DATE  ·  endDate: DATE

cypher
// Q9. Top thread initiators
MATCH (person:Person)<-[:HAS_CREATOR]-(post:Post)<-[:REPLY_OF*0..]-(reply:Message)
WHERE  post.creationDate >= $startDate
  AND  post.creationDate <= $endDate
  AND reply.creationDate >= $startDate
  AND reply.creationDate <= $endDate
RETURN
  person.id,
  person.firstName,
  person.lastName,
  count(DISTINCT post) AS threadCount,
  count(DISTINCT reply) AS messageCount
ORDER BY
  messageCount DESC,
  person.id ASC
LIMIT 100

Q33. Posting summary

Subcategory: Other

Build a histogram over every message created before a given instant, grouped by year, by whether the message is a comment, and by length bucket (<40, <80, <160, rest), reporting count, average length, total length and share of all messages. It does not match a defined type because it summarises a node property (length) with no traversal at all, whereas the aggregation types cover degree statistics, edge-property statistics and path counts.

Source: LDBC SNB BI workload, bi-1.cypher, used unmodified.

Parameters: datetime: DATETIME

cypher
// Q1. Posting summary
MATCH (message:Message)
WHERE message.creationDate < $datetime
WITH count(message) AS totalMessageCountInt // this should be a subquery once Cypher supports it
WITH toFloat(totalMessageCountInt) AS totalMessageCount
MATCH (message:Message)
WHERE message.creationDate < $datetime
  AND message.content IS NOT NULL
WITH
  totalMessageCount,
  message,
  message.creationDate.year AS year
WITH
  totalMessageCount,
  year,
  message:Comment AS isComment,
  CASE
    WHEN message.length <  40 THEN 0
    WHEN message.length <  80 THEN 1
    WHEN message.length < 160 THEN 2
    ELSE                           3
  END AS lengthCategory,
  count(message) AS messageCount,
  sum(message.length) / toFloat(count(message)) AS averageMessageLength,
  sum(message.length) AS sumMessageLength
RETURN
  year,
  isComment,
  lengthCategory,
  messageCount,
  averageMessageLength,
  sumMessageLength,
  messageCount / totalMessageCount AS percentageOfMessages
ORDER BY
  year DESC,
  isComment ASC,
  lengthCategory ASC

Q34. Tag evolution

Subcategory: Other

For every tag of a given TagClass, compare how many messages referenced it in two adjacent 100-day windows and rank by the difference. The distinguishing work is the comparison of two independent time windows and the arithmetic between them, which no defined type covers.

Source: LDBC SNB BI workload, bi-2.cypher, used unmodified.

Parameters: date: DATE  ·  tagClass: STRING

cypher
// Q2. Tag evolution
MATCH (tag:Tag)-[:HAS_TYPE]->(:TagClass {name: $tagClass})
// window 1
OPTIONAL MATCH (message1:Message)-[:HAS_TAG]->(tag)
  WHERE $date <= message1.creationDate
    AND message1.creationDate < $date + duration({days: 100})
WITH tag, count(message1) AS countWindow1
// window 2
OPTIONAL MATCH (message2:Message)-[:HAS_TAG]->(tag)
  WHERE $date + duration({days: 100}) <= message2.creationDate
    AND message2.creationDate < $date + duration({days: 200})
WITH
  tag,
  countWindow1,
  count(message2) AS countWindow2
RETURN
  tag.name,
  countWindow1,
  countWindow2,
  abs(countWindow1 - countWindow2) AS diff
ORDER BY
  diff DESC,
  tag.name ASC
LIMIT 100

Q35. Top message creators in a country

Subcategory: Other

First pick the 100 forums with the most members, then aggregate the message output of those forums’ members, using UNION ALL to keep members with zero messages in the result. It is a two-phase query whose second phase consumes the top-K output of the first, so neither phase alone characterises it.

Source: LDBC SNB BI workload, bi-4.cypher, used unmodified.

Parameters: date: DATE

cypher
// Q4. Top message creators in a country
MATCH (country:Country)<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-(person:Person)<-[:HAS_MEMBER]-(forum:Forum)
WHERE forum.creationDate > $date
WITH country, forum, count(person) AS numberOfMembers
ORDER BY numberOfMembers DESC, forum.id ASC, country.id
WITH DISTINCT forum AS topForum
LIMIT 100

WITH collect(topForum) AS topForums

CALL {
  WITH topForums
  UNWIND topForums AS topForum1
  MATCH (topForum1)-[:CONTAINER_OF]->(post:Post)<-[:REPLY_OF*0..]-(message:Message)-[:HAS_CREATOR]->(person:Person)<-[:HAS_MEMBER]-(topForum2:Forum)
  WITH person, message, topForum2
  WHERE topForum2 IN topForums
  RETURN person, count(DISTINCT message) AS messageCount
UNION ALL
  // Ensure that people who are members of top forums but have 0 messages are also returned.
  // To this end, we return each person with a 0 messageCount
  WITH topForums
  UNWIND topForums AS topForum1
  MATCH (person:Person)<-[:HAS_MEMBER]-(topForum1:Forum)
  RETURN person, 0 AS messageCount
}
RETURN
  person.id AS personId,
  person.firstName AS personFirstName,
  person.lastName AS personLastName,
  person.creationDate AS personCreationDate,
  sum(messageCount) AS messageCount
ORDER BY
  messageCount DESC,
  person.id ASC
LIMIT 100

Q36. Most authoritative users on a given topic

Subcategory: Other

A two-level authority score: person1’s score comes from the person2 who liked their message, and person2’s weight in turn depends on how many likes person2’s own messages received. count(DISTINCT like) ensures each person2’s popularity is added only once per person1. It propagates score through the graph like PageRank but at a fixed depth of two rather than iterating to convergence, so it is neither a degree statistic nor an iterative algorithm.

Source: LDBC SNB BI workload, bi-6.cypher, used unmodified.

Parameters: tag: STRING

cypher
// Q6. Most authoritative users on a given topic
MATCH (tag:Tag {name: $tag})<-[:HAS_TAG]-(message1:Message)-[:HAS_CREATOR]->(person1:Person)
OPTIONAL MATCH (message1)<-[:LIKES]-(person2:Person)
OPTIONAL MATCH (person2)<-[:HAS_CREATOR]-(message2:Message)<-[like:LIKES]-(person3:Person)
RETURN
  person1.id,
  // Using 'DISTINCT like' here ensures that each person2's popularity score is only added once for each person1
  count(DISTINCT like) AS authorityScore
ORDER BY
  authorityScore DESC,
  person1.id ASC
LIMIT 100

// We need to use a redundant computation due to the lack of composable graph queries in the currently supported Cypher version.
// This might change in the future with new Cypher versions and GQL.

Q37. Central Person for a Tag

Subcategory: Other

Score the followers and posters of a tag (100 points for an interest edge, 1 point per message in the window), then add up the same score across each person’s friends and return the highest combined total. The score is a weighted mix of two edge types that is then aggregated over a one-hop neighbourhood, which does not reduce to any single defined type.

Source: LDBC SNB BI workload, bi-8.cypher, used unmodified.

Parameters: tag: STRING  ·  startDate: DATE  ·  endDate: DATE

cypher
// Q8. Central Person for a Tag
MATCH (tag:Tag {name: $tag})
// score
OPTIONAL MATCH (tag)<-[interest:HAS_INTEREST]-(person:Person)
WITH tag, collect(person) AS interestedPersons
OPTIONAL MATCH (tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person:Person)
         WHERE $startDate < message.creationDate
           AND message.creationDate < $endDate
WITH tag, interestedPersons, interestedPersons + collect(person) AS persons
UNWIND persons AS person
WITH DISTINCT tag, person
WITH
  tag,
  person,
  100 * size([(tag)<-[interest:HAS_INTEREST]-(person) | interest]) + size([(tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(person) WHERE $startDate < message.creationDate AND message.creationDate < $endDate | message])
  AS score
OPTIONAL MATCH (person)-[:KNOWS]-(friend)
// We need to use a redundant computation due to the lack of composable graph queries in the currently supported Cypher version.
// This might change in the future with new Cypher versions and GQL.
WITH
  person,
  score,
  100 * size([(tag)<-[interest:HAS_INTEREST]-(friend) | interest]) + size([(tag)<-[:HAS_TAG]-(message:Message)-[:HAS_CREATOR]->(friend) WHERE $startDate < message.creationDate AND message.creationDate < $endDate | message])
  AS friendScore
RETURN
  person.id,
  score,
  sum(friendScore) AS friendsScore
ORDER BY
  score + friendsScore DESC,
  person.id ASC
LIMIT 100

Q38. Experts in social circle

Subcategory: Other

Among the persons whose KNOWS distance from a start person falls in the ring [minPathDistance, maxPathDistance], find those who live in a given country and have posted about a given TagClass. The ring is obtained by taking two reachable sets with apoc.path.subgraphNodes and subtracting the nearer one. It goes beyond a neighbourhood query because the ring is then joined against a location pattern and a TagClass pattern and aggregated per tag.

Source: LDBC SNB BI workload, bi-10.cypher, used unmodified.

Parameters: personId: ID  ·  country: STRING  ·  tagClass: STRING  ·  minPathDistance: INT  ·  maxPathDistance: INT

cypher
// Q10. Experts in social circle
// Requires the Neo4j APOC library
MATCH (startPerson:Person {id: $personId})
CALL apoc.path.subgraphNodes(startPerson, {
	relationshipFilter: "KNOWS",
    minLevel: 1,
    maxLevel: $minPathDistance-1
})
YIELD node
WITH startPerson, collect(DISTINCT node) AS nodesCloserThanMinPathDistance
CALL apoc.path.subgraphNodes(startPerson, {
	relationshipFilter: "KNOWS",
    minLevel: 1,
    maxLevel: $maxPathDistance
})
YIELD node
WITH nodesCloserThanMinPathDistance, collect(DISTINCT node) AS nodesCloserThanMaxPathDistance
// compute the difference of sets: nodesCloserThanMaxPathDistance - nodesCloserThanMinPathDistance
WITH [n IN nodesCloserThanMaxPathDistance WHERE NOT n IN nodesCloserThanMinPathDistance] AS expertCandidatePersons
UNWIND expertCandidatePersons AS expertCandidatePerson
MATCH
  (expertCandidatePerson)-[:IS_LOCATED_IN]->(:City)-[:IS_PART_OF]->(:Country {name: $country}),
  (expertCandidatePerson)<-[:HAS_CREATOR]-(message:Message)-[:HAS_TAG]->(:Tag)-[:HAS_TYPE]->
  (:TagClass {name: $tagClass})
MATCH
  (message)-[:HAS_TAG]->(tag:Tag)
RETURN
  expertCandidatePerson.id,
  tag.name,
  count(DISTINCT message) AS messageCount
ORDER BY
  messageCount DESC,
  tag.name ASC,
  expertCandidatePerson.id ASC
LIMIT 100
This query requires the Neo4j APOC library. It calls apoc.path.subgraphNodes twice and takes the set difference to isolate the ring of persons at a given KNOWS distance.

Q39. Zombies in a country

Subcategory: Other

Select the zombie accounts of a country by a rate test (fewer than one message per month over the account’s lifetime), then compute for each zombie the share of its likes that came from other zombies. The query first derives a subpopulation and then measures a ratio within it, which no defined type describes.

Source: LDBC SNB BI workload, bi-13.cypher, used unmodified.

Parameters: country: STRING  ·  endDate: DATE

cypher
// Q13. Zombies in a country
MATCH (country:Country {name: $country})<-[:IS_PART_OF]-(:City)<-[:IS_LOCATED_IN]-(zombie:Person)
WHERE zombie.creationDate < $endDate
WITH country, zombie
OPTIONAL MATCH (zombie)<-[:HAS_CREATOR]-(message:Message)
WHERE message.creationDate < $endDate
WITH
  country,
  zombie,
  count(message) AS messageCount
WITH
  country,
  zombie,
  12 * ($endDate.year  - zombie.creationDate.year )
     + ($endDate.month - zombie.creationDate.month)
     + 1 AS months,
  messageCount
WHERE messageCount / months < 1
WITH
  country,
  collect(zombie) AS zombies
UNWIND zombies AS zombie
OPTIONAL MATCH
  (zombie)<-[:HAS_CREATOR]-(message:Message)<-[:LIKES]-(likerZombie:Person)
WHERE likerZombie IN zombies
WITH
  zombie,
  count(likerZombie) AS zombieLikeCount
OPTIONAL MATCH
  (zombie)<-[:HAS_CREATOR]-(message:Message)<-[:LIKES]-(likerPerson:Person)
WHERE likerPerson.creationDate < $endDate
WITH
  zombie,
  zombieLikeCount,
  count(likerPerson) AS totalLikeCount
RETURN
  zombie.id,
  zombieLikeCount,
  totalLikeCount,
  CASE totalLikeCount
    WHEN 0 THEN 0.0
    ELSE zombieLikeCount / toFloat(totalLikeCount)
  END AS zombieScore
ORDER BY
  zombieScore DESC,
  zombie.id ASC
LIMIT 100

Q40. Fake news detection

Subcategory: Other

For each of two (tag, date) combinations, find the persons who posted about that tag on that day and who have at most maxKnowsLimit friends doing the same, then keep only the persons present in both. UNWIND plus a CALL subquery runs the same correlated pattern twice and the results are intersected, so the query is a parameterised self-join rather than a plain set operation over two label values.

Source: LDBC SNB BI workload, bi-16.cypher, used unmodified.

Parameters: tagA: STRING  ·  dateA: DATE  ·  tagB: STRING  ·  dateB: DATE  ·  maxKnowsLimit: INT

cypher
// Q16. Fake news detection
// These parameters return a 'false positive' as the maxKnowsLimit is set too high.
UNWIND [
    {letter: 'A', tag: $tagA, date: $dateA},
    {letter: 'B', tag: $tagB, date: $dateB}
  ] AS param
WITH param.letter AS paramLetter, param.tag AS paramTagX, param.date AS paramDateX
CALL {
  WITH paramTagX, paramDateX
  MATCH (person1:Person)<-[:HAS_CREATOR]-(message1:Message)-[:HAS_TAG]->(tag:Tag {name: paramTagX})
  WHERE date(message1.creationDate) = date(paramDateX)
  // filter out Persons with more than $maxKnowsLimit friends who created the same kind of Message
  OPTIONAL MATCH (person1)-[:KNOWS]-(person2:Person)<-[:HAS_CREATOR]-(message2:Message)-[:HAS_TAG]->(tag)
  WHERE date(message2.creationDate) = date(paramDateX)
  WITH person1, count(DISTINCT message1) AS cm, count(DISTINCT person2) AS cp2
  WHERE cp2 <= $maxKnowsLimit
  // return count
  RETURN person1, cm
}
WITH person1, collect({letter: paramLetter, messageCount: cm}) AS results
WHERE size(results) = 2
RETURN
  person1.id,
  [r IN results WHERE r.letter = 'A' | r.messageCount][0] AS messageCountA,
  [r IN results WHERE r.letter = 'B' | r.messageCount][0] AS messageCountB
ORDER BY messageCountA + messageCountB DESC, person1.id ASC
LIMIT 20

Q41. Information propagation analysis

Subcategory: Other

Structurally the heaviest query in the set: one MATCH co-joins six patterns spanning two distinct forums, three persons and two variable-length REPLY_OF*0.. segments, then requires message2 to be more than delta hours later than message1 and negates person1’s membership of forum2. It is a branched multi-way join with a temporal predicate, not a line, a cycle or a clique.

Source: LDBC SNB BI workload, bi-17.cypher, used unmodified.

Parameters: tag: STRING  ·  delta: INT

cypher
// Q17. Information propagation analysis
MATCH
  (tag:Tag {name: $tag}),
  (person1:Person)<-[:HAS_CREATOR]-(message1:Message)-[:REPLY_OF*0..]->(post1:Post)<-[:CONTAINER_OF]-(forum1:Forum),
  (message1)-[:HAS_TAG]->(tag),
// Having two HAS_MEMBER edges in the same MATCH clause ensures that person2 and person3 are different
// as Cypher's edge-isomorphic matching does not allow for such a match in a single MATCH clause.
  (forum1)<-[:HAS_MEMBER]->(person2:Person)<-[:HAS_CREATOR]-(comment:Comment)-[:HAS_TAG]->(tag),
  (forum1)<-[:HAS_MEMBER]->(person3:Person)<-[:HAS_CREATOR]-(message2:Message),
  (comment)-[:REPLY_OF]->(message2)-[:REPLY_OF*0..]->(post2:Post)<-[:CONTAINER_OF]-(forum2:Forum)
// The query allows message2 = post2. If this is the case, their HAS_TAG edges to tag overlap,
// and Cypher's edge-isomorphic matching does not allow for such a match in a single MATCH clause.
// To work around this, we add them in separate MATCH clauses.
MATCH (comment)-[:HAS_TAG]->(tag)
MATCH (message2)-[:HAS_TAG]->(tag)
WHERE forum1 <> forum2
  AND message2.creationDate > message1.creationDate + duration({hours: $delta})
  AND NOT (forum2)-[:HAS_MEMBER]->(person1)
RETURN person1.id, count(DISTINCT message2) AS messageCount
ORDER BY messageCount DESC, person1.id ASC
LIMIT 10