Skip to main content

Modeling Hierarchies, Networks, and Feedback Loops

Not all graphs are the same shape. A file system is a tree. A social network is many-to-many. A supply chain with feedback has cycles. Each shape has different query patterns, different failure modes, and different production constraints.

This tutorial covers all three so you can recognize which shape your domain needs and choose the right query approach from the start.


Shape 1: Hierarchies (trees)

Example domain: Organizational chart — COMPANY → DIVISION → DEPARTMENT → TEAM → EMPLOYEE

Ingesting the tree

from rushdb import RushDB

db = RushDB("RUSHDB_API_KEY", base_url="https://api.rushdb.com/api/v1")

company = db.records.create("COMPANY", {"name": "Acme Corp"})
division = db.records.create("DIVISION", {"name": "Engineering"})
dept = db.records.create("DEPARTMENT", {"name": "Platform", "budget": 2000000})
team_infra = db.records.create("TEAM", {"name": "Infra", "size": 6})
lena = db.records.create("EMPLOYEE", {"name": "Lena Müller", "role": "Lead SRE", "level": "L5"})
marco = db.records.create("EMPLOYEE", {"name": "Marco Rossi", "role": "Engineer", "level": "L4"})

db.records.attach(company.id, division.id, {"type": "HAS_DIVISION"})
db.records.attach(division.id, dept.id, {"type": "HAS_DEPARTMENT"})
db.records.attach(dept.id, team_infra.id, {"type": "HAS_TEAM"})
db.records.attach(team_infra.id, lena.id, {"type": "MEMBER_OF", "direction": "in"})
db.records.attach(team_infra.id, marco.id, {"type": "MEMBER_OF", "direction": "in"})

Querying the tree: all employees with their full org path

headcount = db.records.find({
"labels": ["EMPLOYEE"],
"where": {
"TEAM": {
"$alias": "$team",
"$relation": {"type": "MEMBER_OF", "direction": "out"},
"DEPARTMENT": {
"$alias": "$dept",
"DIVISION": {
"$alias": "$div",
"COMPANY": {"name": "Acme Corp"}
}
}
}
},
"select": {
"employeeName": "$record.name",
"role": "$record.role",
"teamName": "$team.name",
"deptName": "$dept.name"
},
"orderBy": {"employeeName": "asc"}
})

Tree query: headcount per department

dept_headcount = db.records.find({
"labels": ["DEPARTMENT"],
"where": {
"TEAM": {
"EMPLOYEE": {"$alias": "$emp", "$relation": {"type": "MEMBER_OF", "direction": "out"}}
},
"DIVISION": {"COMPANY": {"name": "Acme Corp"}}
},
"select": {
"deptName": "$record.name",
"headcount": {"$count": "$emp"}
},
"groupBy": ["deptName", "headcount"],
"orderBy": {"headcount": "desc"}
})

Same-label trees: variable-length traversal with hops

The org chart above mixes labels — every level is a different label, so every level gets its own nested block. That manual nesting is exactly right for mixed-label paths: it lets you alias and filter each level independently.

But many trees repeat one label and one relationship type at every level: category trees (CATEGORY -[HAS_CHILD]-> CATEGORY), folder trees, management chains. Nesting the same block once per level hard-codes the depth and gets verbose fast. For those, use variable-length traversal: add hops to $relation and cover N levels in a single block.

All descendants of the "Electronics" category, up to 4 levels deep:

descendants = db.records.find({
"labels": ["CATEGORY"],
"where": {
"CATEGORY": {
"$relation": {"type": "HAS_CHILD", "direction": "in", "hops": {"min": 1, "max": 4}},
"name": "Electronics"
}
}
})

How hops behaves:

  • hops: 3 matches exactly 3 hops; hops: { min, max } matches a range (min defaults to 1).
  • type and direction constrain every hop; the nested label and its criteria constrain only the endpoint record. Intermediate records are anonymous and unconstrained.
  • hops.max is capped per deployment (RUSHDB_MAX_TRAVERSAL_HOPS, default 10). Omitting max requests unbounded traversal, which is only allowed on self-hosted deployments and projects with a custom Neo4j instance.

Keep manual nesting when levels carry different labels, or when you need to alias or filter intermediate levels — hops cannot express per-level criteria.


Shape 2: Many-to-many networks

Example domain: Research graph — AUTHOR ↔ PAPER ↔ TOPIC, PAPER → PAPER (citations)

Ingesting the network

lena_author = db.records.create("AUTHOR", {"name": "Lena Müller", "hIndex": 14})
marco_author = db.records.create("AUTHOR", {"name": "Marco Rossi", "hIndex": 9})
topic_gnn = db.records.create("TOPIC", {"name": "Graph Neural Networks"})
paper1 = db.records.create("PAPER", {"title": "GNN Scaling Strategies", "year": 2024, "citations": 87})
paper2 = db.records.create("PAPER", {"title": "Attention is All You Need", "year": 2017, "citations": 90000})

db.records.attach(lena_author.id, paper1.id, {"type": "CO_AUTHORED"})
db.records.attach(marco_author.id, paper1.id, {"type": "CO_AUTHORED"})
db.records.attach(paper1.id, topic_gnn.id, {"type": "COVERS"})
db.records.attach(paper1.id, paper2.id, {"type": "CITES"})

Network query: co-authors on a topic

co_authors = db.records.find({
"labels": ["AUTHOR"],
"where": {
"PAPER": {
"$alias": "$paper",
"$relation": {"type": "CO_AUTHORED", "direction": "out"},
"TOPIC": {"name": "Graph Neural Networks"}
}
},
"select": {
"authorName": "$record.name",
"hIndex": "$record.hIndex",
"paperCount": {"$count": "$paper"}
},
"groupBy": ["authorName", "hIndex", "paperCount"],
"orderBy": {"paperCount": "desc"}
})

Shape 3: Cyclic systems (dependency graphs)

Example domain: Package dependency graph — PACKAGE depends on other PACKAGEs through transitive chains.

SearchQuery supports variable-length traversal natively: add hops to $relation to follow a relationship pattern up to N hops in a single block. Traversal depth is bounded by a per-deployment cap (RUSHDB_MAX_TRAVERSAL_HOPS, default 10); truly unbounded traversal (omitting max) is available only on self-hosted deployments and projects with a custom Neo4j instance. Explicitly scoping traversal to the depth your product requires remains good practice — for blast-radius analysis (which packages are affected if crypto-utils has a CVE?), traverse up to a known safe depth.

Ingesting the dependency graph

app_core = db.records.create("PACKAGE", {"name": "app-core", "version": "2.1.0"})
auth_lib = db.records.create("PACKAGE", {"name": "auth-lib", "version": "1.4.2"})
data_client = db.records.create("PACKAGE", {"name": "data-client", "version": "3.0.1"})
crypto_utils = db.records.create("PACKAGE", {"name": "crypto-utils", "version": "0.9.8"})

db.records.attach(app_core.id, auth_lib.id, {"type": "DEPENDS_ON"})
db.records.attach(app_core.id, data_client.id, {"type": "DEPENDS_ON"})
db.records.attach(auth_lib.id, crypto_utils.id, {"type": "DEPENDS_ON"})
db.records.attach(data_client.id, crypto_utils.id, {"type": "DEPENDS_ON"})

Cyclic query: two-hop blast radius for a vulnerable package

Find all packages that depend on crypto-utils directly (hop 1) or through one intermediate package (hop 2):

# Direct dependents of crypto-utils
direct = db.records.find({
"labels": ["PACKAGE"],
"where": {
"PACKAGE": {
"$alias": "$dep",
"$relation": {"type": "DEPENDS_ON", "direction": "out"},
"name": "crypto-utils"
}
},
"select": {
"packageName": "$record.name",
"version": "$record.version"
}
})

# Two-hop: packages that depend on a package that depends on crypto-utils
indirect = db.records.find({
"labels": ["PACKAGE"],
"where": {
"PACKAGE": {
"$alias": "$mid",
"$relation": {"type": "DEPENDS_ON", "direction": "out"},
"PACKAGE": {
"$relation": {"type": "DEPENDS_ON", "direction": "out"},
"name": "crypto-utils"
}
}
},
"select": {
"packageName": "$record.name",
"via": "$mid.name"
}
})

Manual nesting like this is still useful when you want to name and select the intermediate package (via: '$mid.name'). But when you only need the affected set, one variable-length block replaces the whole ladder.

Cyclic query: N-hop blast radius with hops

All packages that depend on crypto-utils directly or transitively, up to 4 hops, in a single query:

blast_radius = db.records.find({
"labels": ["PACKAGE"],
"where": {
"PACKAGE": {
"$relation": {"type": "DEPENDS_ON", "direction": "out", "hops": {"min": 1, "max": 4}},
"name": "crypto-utils"
}
},
"select": {
"packageName": "$record.name",
"version": "$record.version"
}
})

type and direction apply to every hop, so this only walks DEPENDS_ON edges in the dependency direction. The name: 'crypto-utils' criterion filters the endpoint of the path — intermediate packages stay anonymous.

One caveat when aggregating over multihop matches: the traversal produces one row per path. $count and $collect deduplicate by default, but $sum/$avg over a multihop alias will count an endpoint once per path leading to it.

Cyclic query: detecting circular dependencies with $cycle

Cyclic graphs raise a question tree-shaped data never does: does anything depend on itself, directly or transitively? $cycle finds records that sit on a closed path back to themselves:

cyclic_packages = db.records.find({
"labels": ["PACKAGE"],
"where": {
"$cycle": {"type": "DEPENDS_ON", "direction": "out", "hops": {"min": 2, "max": 6}}
}
})

$cycle rules:

  • $cycle is a record-level predicate: its value is the traversal spec itself — type, direction, hops — with hops mandatory (min ≥ 2, and it defaults to 2 — a 1-hop "cycle" would be a self-loop). It accepts nothing else: no $alias, no property criteria, no nested labels. Both ends of the path are the root record, so filter the root instead; intermediate node labels are unconstrained.
  • direction: 'out' matters: it makes the query mean "A depends on B depends on … depends on A". An undirected cycle would also match harmless mutual pairs.
  • Trail semantics apply: each relationship is used once per path, but records may repeat — figure-eight shapes count as cycles.
  • The query returns cycle participants, not the rings themselves. To reconstruct which packages form a specific cycle, walk outward from a participant with bounded one-hop queries.

Comparison of the three shapes

ShapeKey propertyQuery patternAmbush
TreeSingle parent per nodeNested blocks for mixed-label paths; hops for same-label depthPer-level filters/aliases need manual nesting — hops skips the middle
Many-to-manyNodes can appear in multiple relationshipsAggregation by relationship typeFan-out can be large without limit
CyclicLoops are possiblehops for blast radius, $cycle for loop detectionhops.max is capped per deployment (default 10); keep it small anyway

Production caveat

Each shape has a fan-out risk. In trees, deep hierarchies multiply candidates at every hop. In networks, highly-connected hubs (an author with 200 papers) inflate traversal cost. In cyclic graphs, even a two-hop traversal can cover thousands of paths in large dependency graphs.

Variable-length traversal explores every matching path, so treat hops.max as a budget: keep it as small as your use case allows, and always set type and direction — an undirected, untyped hops query is the most expensive shape. hops.max is capped per deployment (RUSHDB_MAX_TRAVERSAL_HOPS, default 10); unbounded traversal is only available on self-hosted deployments and custom Neo4j instances, bounded there by the transaction timeout.

Apply limit conservatively and filter early on high-selectivity properties (e.g. name, status, version). Measure response times before and after adding hops to your query.


Next steps