From 956b255222b5b04923cb30878842a923bcd51bc0 Mon Sep 17 00:00:00 2001 From: Justin Tout <3639630+justintout@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:05:20 -0400 Subject: [PATCH] Add BreadthFirst traversal mode A multi-hop step deduplicates on (node, depth), so a node reachable at several depths is expanded at each. BreadthFirst compiles the step to one CTE per hop level holding nodes first reached at that level, so each node is expanded once. Results are identical. Each level costs a fixed ~20us, so which mode wins depends on how much the walk revisits nodes, which the query cannot predict. It is opt-in: 10 hops on a dense 500-node graph drops from 9.5ms to 2.0ms, and on a 100-node chain rises from 40us to 243us. It requires minHops of 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YB9owLYxU6NsNuVk2NVc7C --- README.md | 8 +++++ bench_test.go | 52 +++++++++++++++++++++++++++++++ integration_test.go | 75 +++++++++++++++++++++++++++------------------ query.go | 27 ++++++++++++++++ query_compile.go | 59 +++++++++++++++++++++++++++++++++-- 5 files changed, 189 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0f28130..b711ac2 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,14 @@ g.Match("Person"). RelatedDir("KNOWS", graph.Incoming, 1, 1). // who knows Bob? Run(ctx) +// Expand each reached node once. Faster on dense graphs with cycles, +// slower on chains and sparse graphs; see Query.BreadthFirst. +g.Match("Person"). + Where("name", "=", "Alice"). + Related("KNOWS", 1, 6). + BreadthFirst(). + Run(ctx) + // Project columns and property paths; unreturned fields stay zero. g.Match("Person"). Related("KNOWS", 1, 3). diff --git a/bench_test.go b/bench_test.go index 7fcfd33..494ac97 100644 --- a/bench_test.go +++ b/bench_test.go @@ -475,3 +475,55 @@ func BenchmarkReturn(b *testing.B) { }) } } + +// BenchmarkBreadthFirst compares the default walk with BreadthFirst on graph +// shapes where each wins: chains and trees never revisit nodes, while dense +// graphs with cycles revisit them at many depths. +func BenchmarkBreadthFirst(b *testing.B) { + ctx := context.Background() + chain := openBenchGraph(b) + buildChainGraph(b, chain, 100) + tree := openBenchGraph(b) + buildFanoutGraph(b, tree, 6, 3) + dense := openBenchGraph(b) + buildDenseGraph(b, dense, 500, 5) + large := openBenchGraph(b) + buildRandomGraphTx(b, large, 10000, 3, func(i int) map[string]any { return nil }) + + cases := []struct { + name string + query func() *Query + }{ + {"chain/hops=10", func() *Query { return chain.Match("Node").Where("name", "=", "n0").Related("LINK", 1, 10) }}, + {"tree/depth=6", func() *Query { return tree.Match("Root").Related("CHILD", 1, 6) }}, + {"dense/hops=5", func() *Query { return dense.Match("Node").Where("name", "=", "n0").Related("LINK", 1, 5) }}, + {"dense/hops=10", func() *Query { return dense.Match("Node").Where("name", "=", "n0").Related("LINK", 1, 10) }}, + {"large/out/hops=6", func() *Query { return large.Match("Node").Where("name", "=", "n0").Related("LINK", 1, 6) }}, + {"large/both/hops=4", func() *Query { + return large.Match("Node").Where("name", "=", "n0").RelatedDir("LINK", Both, 1, 4) + }}, + } + for _, c := range cases { + for _, bfs := range []bool{false, true} { + name := c.name + "/default" + if bfs { + name = c.name + "/breadth-first" + } + b.Run(name, func(b *testing.B) { + for range b.N { + q := c.query() + if bfs { + q = q.BreadthFirst() + } + res, err := q.Run(ctx) + if err != nil { + b.Fatal(err) + } + if res.Len() == 0 { + b.Fatal("expected results") + } + } + }) + } + } +} diff --git a/integration_test.go b/integration_test.go index 69962c3..7a254fa 100644 --- a/integration_test.go +++ b/integration_test.go @@ -222,19 +222,22 @@ func TestWhereRelIntermediateStep(t *testing.T) { ctx := context.Background() buildSocialGraph(t, g) - // Alice knows Bob and Charlie within 2 hops; only Charlie passes the - // filter, and Charlie works nowhere. - res, err := g.Match("Person"). - Where("name", "=", "Alice"). - Related("KNOWS", 1, 2). - WhereRel("name", "=", "Charlie"). - Related("WORKS_AT", 1, 1). - Run(ctx) - if err != nil { - t.Fatal(err) - } - if res.Len() != 0 { - t.Errorf("got %v, want none", nodeNames(res.Nodes())) + // Alice knows Bob and Charlie within 2 hops. Filtering to Bob reaches + // Globex; filtering to Charlie, who works nowhere, reaches nothing. + for _, bfs := range []bool{false, true} { + for filter, want := range map[string]int{"Bob": 1, "Charlie": 0} { + q := g.Match("Person").Where("name", "=", "Alice").Related("KNOWS", 1, 2) + if bfs { + q = q.BreadthFirst() + } + res, err := q.WhereRel("name", "=", filter).Related("WORKS_AT", 1, 1).Run(ctx) + if err != nil { + t.Fatal(err) + } + if res.Len() != want { + t.Errorf("breadthFirst=%v filter=%s: got %v, want %d results", bfs, filter, nodeNames(res.Nodes()), want) + } + } } } @@ -534,25 +537,39 @@ func TestTraversalMatchesReference(t *testing.T) { for _, minHops := range []int{1, 2} { for maxHops := minHops; maxHops <= 6; maxHops++ { for _, start := range []int{0, 17, 42} { - res, err := g.Match("V"). - Where("name", "=", fmt.Sprintf("v%d", start)). - RelatedDir("E", dir, minHops, maxHops). - Return("name"). - Run(ctx) - if err != nil { - t.Fatal(err) - } - want := reference(start, dir, minHops, maxHops) - got := map[string]bool{} - for _, node := range res.Nodes() { - got[node.Name] = true - } - if res.Len() != len(got) || !maps.Equal(got, want) { - t.Errorf("dir=%d hops=%d..%d start=v%d: got %d nodes (%d rows), want %d", - dir, minHops, maxHops, start, len(got), res.Len(), len(want)) + for _, bfs := range []bool{false, true} { + if bfs && minHops != 1 { + continue + } + q := g.Match("V"). + Where("name", "=", fmt.Sprintf("v%d", start)). + RelatedDir("E", dir, minHops, maxHops) + if bfs { + q = q.BreadthFirst() + } + res, err := q.Return("name").Run(ctx) + if err != nil { + t.Fatal(err) + } + want := reference(start, dir, minHops, maxHops) + got := map[string]bool{} + for _, node := range res.Nodes() { + got[node.Name] = true + } + if res.Len() != len(got) || !maps.Equal(got, want) { + t.Errorf("dir=%d hops=%d..%d start=v%d breadthFirst=%v: got %d nodes (%d rows), want %d", + dir, minHops, maxHops, start, bfs, len(got), res.Len(), len(want)) + } } } } } } } + +func TestBreadthFirstRequiresMinHopsOne(t *testing.T) { + g := openTestGraph(t) + if _, err := g.Match("V").Related("E", 2, 4).BreadthFirst().Run(context.Background()); err == nil { + t.Error("BreadthFirst accepted minHops of 2") + } +} diff --git a/query.go b/query.go index 9c82d70..687e09b 100644 --- a/query.go +++ b/query.go @@ -62,6 +62,8 @@ type relStep struct { minHops int maxHops int wheres []whereClause + // breadthFirst compiles the step with writeBFS. + breadthFirst bool } // Query is a fluent builder for graph traversal queries. @@ -133,6 +135,31 @@ func (q *Query) RelatedDir(edgeType string, dir Direction, minHops, maxHops int) return q } +// BreadthFirst makes the most recent Related() step expand each reached node +// once. By default a multi-hop step deduplicates on (node, depth), so a node +// reachable at several depths is expanded at each of them. Results are the +// same either way. +// +// Breadth-first pays a fixed cost per hop level, so it helps only when the +// walk revisits many nodes, as on densely connected graphs with cycles. On a +// 500-node graph with 5 edges per node, a 10-hop step drops from 9.5ms to +// 2.0ms; on a 100-node chain it rises from 40µs to 243µs. Measure before +// choosing it. It requires minHops == 1: nodes reachable only at an exact +// larger depth are discarded by a visited set. +func (q *Query) BreadthFirst() *Query { + if len(q.rels) == 0 { + q.err = fmt.Errorf("graph: BreadthFirst called without a preceding Related()") + return q + } + r := &q.rels[len(q.rels)-1] + if r.minHops != 1 { + q.err = fmt.Errorf("graph: BreadthFirst requires minHops of 1, got %d", r.minHops) + return q + } + r.breadthFirst = r.maxHops > 1 + return q +} + // WhereRel adds a column filter on nodes reached in the most recent Related() step. func (q *Query) WhereRel(field, op string, value any) *Query { if err := validateWhere(field, op); err != nil { diff --git a/query_compile.go b/query_compile.go index 6b68b5e..0dc6825 100644 --- a/query_compile.go +++ b/query_compile.go @@ -155,6 +155,8 @@ func writeStep(b *sqlBuilder, i int, r relStep) { b.w(", ", reached, "(node_id) AS (") writeHops(b, prev, r, hopAll, "") b.w(")") + case r.breadthFirst: + writeBFS(b, step, prev, reached, r) default: // UNION deduplicates on (node_id, depth), which bounds the work per // depth level by the node count and stops cycles. @@ -171,6 +173,47 @@ func writeStep(b *sqlBuilder, i int, r relStep) { } } +// writeBFS appends CTEs reaching nodes within 1..maxHops of prev, expanding +// each node once. The recursive CTE deduplicates on (node, depth), so on +// graphs with cycles it re-expands a node at every depth it is reachable. +// Here frontier f holds nodes first reached at depth k: the expansion of +// f minus all earlier frontiers. A start node is not excluded, so it is +// reached again through a cycle, matching the recursive form. +// +// Each level costs a fixed ~20µs to build its temporary tables, so this only +// wins when the walk revisits many nodes; see Query.BreadthFirst. +func writeBFS(b *sqlBuilder, step, prev, reached string, r relStep) { + front := func(k int) string { return "f" + step + "_" + strconv.Itoa(k) } + for k := 1; k <= r.maxHops; k++ { + from := prev + exclude := "" + if k > 1 { + from = front(k - 1) + var ex strings.Builder + for j := 1; j < k; j++ { + if j > 1 { + ex.WriteString(" UNION ALL ") + } + ex.WriteString("SELECT node_id FROM " + front(j)) + } + exclude = ex.String() + } + // MATERIALIZED keeps each frontier from being re-evaluated by every + // later level that excludes it. + b.w(", ", front(k), "(node_id) AS MATERIALIZED (") + writeHops(b, from, r, hopNew, exclude) + b.w(")") + } + b.w(", ", reached, "(node_id) AS (") + for k := 1; k <= r.maxHops; k++ { + if k > 1 { + b.w(" UNION ALL ") + } + b.w("SELECT node_id FROM ", front(k)) + } + b.w(")") +} + // hopMode selects how writeHops shapes its SELECT. type hopMode int @@ -180,10 +223,13 @@ const ( // hopRecursive is the recursive member of a walk CTE: it carries // depth + 1 and filters on the depth condition. hopRecursive + // hopNew selects distinct reached nodes not in the exclusion subquery. + hopNew ) // writeHops appends a SELECT of the nodes one hop from table from along r. -// cond is the depth condition for hopRecursive. +// cond is the depth condition for hopRecursive, or the exclusion subquery for +// hopNew (empty to exclude nothing). // Both directions compile to two SELECTs, one per endpoint column, so each can // use its covering index; an OR in the join condition defeats index use. func writeHops(b *sqlBuilder, from string, r relStep, mode hopMode, cond string) { @@ -206,13 +252,20 @@ func writeHops(b *sqlBuilder, from string, r relStep, mode hopMode, cond string) b.w(" UNION ") } } - b.w("SELECT e.", s.far) + b.w("SELECT ") + if mode == hopNew && len(sides) == 1 { + b.w("DISTINCT ") + } + b.w("e.", s.far) if mode == hopRecursive { b.w(", p.depth + 1") } b.w(" FROM ", from, " p CROSS JOIN edges e ON e.", s.near, " = p.node_id AND e.type = ?") - if mode == hopRecursive { + switch { + case mode == hopRecursive: b.w(" WHERE ", cond) + case mode == hopNew && cond != "": + b.w(" WHERE e.", s.far, " NOT IN (", cond, ")") } b.args = append(b.args, r.edgeType) }