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) }