Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
52 changes: 52 additions & 0 deletions bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
})
}
}
}
75 changes: 46 additions & 29 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}

Expand Down Expand Up @@ -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")
}
}
27 changes: 27 additions & 0 deletions query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 56 additions & 3 deletions query_compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<k> holds nodes first reached at depth k: the expansion of
// f<k-1> 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

Expand All @@ -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) {
Expand All @@ -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)
}
Expand Down