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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.tmp/
84 changes: 50 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ All CRUD methods are available on both `*Graph` and `*Tx`.

### Query builder

The query builder compiles to SQL. Simple matches use JOINs; multi-hop traversals use recursive CTEs. Cycles are handled safely via `UNION` deduplication with a configurable depth cap (max 10 hops).
The query builder compiles to SQL. Each traversal step compiles to a CTE of node IDs; multi-hop steps use recursive CTEs. Cycles are handled safely via `UNION` deduplication with a configurable depth cap (max 10 hops).

```go
// Match by label.
Expand All @@ -119,13 +119,13 @@ g.Match("Person").Where("name", "=", "Alice").Run(ctx)
// Filter on JSON properties.
g.Match("Person").WhereJSON("age", ">", 30).Run(ctx)

// Single-hop traversal (compiles to JOIN).
// Single-hop traversal.
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 1).
Run(ctx)

// Multi-hop traversal (compiles to recursive CTE).
// Multi-hop traversal (recursive CTE).
g.Match("Person").
Where("name", "=", "Alice").
Related("KNOWS", 1, 3).
Expand All @@ -145,11 +145,27 @@ 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).
Return("name", "age").
Run(ctx)

// Count and pagination.
count, _ := g.Match("Person").Count(ctx)
results, _ := g.Match("Person").Limit(10).Offset(20).Run(ctx)
```

`Where` and `WhereRel` accept the node columns `id`, `name`, `created_at`, `updated_at`, and `properties`. Filter properties with `WhereJSON` and `WhereRelJSON`.

### Results

```go
Expand Down Expand Up @@ -180,42 +196,42 @@ count := res.Len()
go test -bench=. -benchmem ./...
```

Results on Apple M3 Max:
Results on Apple M3 Max, in-memory database unless noted:

```
BenchmarkTraversalChain/hops=1 37976 31510 ns/op 2707 B/op 61 allocs/op
BenchmarkTraversalChain/hops=2 15195 79016 ns/op 4170 B/op 70 allocs/op
BenchmarkTraversalChain/hops=3 12860 93882 ns/op 4917 B/op 86 allocs/op
BenchmarkTraversalChain/hops=5 10000 115139 ns/op 6408 B/op 117 allocs/op
BenchmarkTraversalChain/hops=10 6820 175823 ns/op 10102 B/op 194 allocs/op

BenchmarkTraversalFanout/depth=2 18614 64336 ns/op 11518 B/op 217 allocs/op (13 nodes)
BenchmarkTraversalFanout/depth=4 926 1307569 ns/op 92186 B/op 1840 allocs/op (121 nodes)
BenchmarkTraversalFanout/depth=6 12 93185375 ns/op 828125 B/op 16426 allocs/op (1093 nodes)

BenchmarkTraversalDense/hops=1 3205 378662 ns/op 5734 B/op 129 allocs/op (500 nodes, 5 edges/node)
BenchmarkTraversalDense/hops=2 220 5481183 ns/op 25130 B/op 524 allocs/op
BenchmarkTraversalDense/hops=3 45 25483407 ns/op 104028 B/op 2218 allocs/op
BenchmarkTraversalDense/hops=5 5 209771308 ns/op 366190 B/op 7815 allocs/op
BenchmarkTraversalDense/hops=10 2 931694438 ns/op 375852 B/op 8013 allocs/op

BenchmarkCreateNode 78876 15556 ns/op 1817 B/op 38 allocs/op
BenchmarkCreateEdge 83337 15518 ns/op 818 B/op 19 allocs/op
BenchmarkBulkInsertTx/batch=10 6548 204779 ns/op 14577 B/op 382 allocs/op
BenchmarkBulkInsertTx/batch=100 715 1800590 ns/op 141614 B/op 3801 allocs/op
BenchmarkBulkInsertTx/batch=1000 70 16154103 ns/op 1437031 B/op 39484 allocs/op

BenchmarkMatchSimple/nodes=100 51266 23259 ns/op 1525 B/op 29 allocs/op
BenchmarkMatchSimple/nodes=1000 7406 164950 ns/op 1523 B/op 29 allocs/op
BenchmarkMatchSimple/nodes=10000 786 1526852 ns/op 1526 B/op 29 allocs/op
BenchmarkTraversalChain/hops=1 7.6µs 2220 B/op 33 allocs/op (100-node chain)
BenchmarkTraversalChain/hops=10 31.4µs 8889 B/op 173 allocs/op

BenchmarkTraversalFanout/depth=2 33.1µs 10417 B/op 201 allocs/op (13 nodes)
BenchmarkTraversalFanout/depth=4 199.0µs 90999 B/op 1824 allocs/op (121 nodes)
BenchmarkTraversalFanout/depth=6 1.92ms 826277 B/op 16408 allocs/op (1093 nodes)

BenchmarkTraversalDense/hops=1 16.2µs 5263 B/op 101 allocs/op (500 nodes, 5 edges/node)
BenchmarkTraversalDense/hops=3 279.0µs 102656 B/op 2196 allocs/op
BenchmarkTraversalDense/hops=10 9.72ms 373273 B/op 7985 allocs/op

BenchmarkTraversalLarge/out/hops=1 13.0µs 3738 B/op 68 allocs/op (10k nodes, 3 edges/node)
BenchmarkTraversalLarge/out/hops=3 98.8µs 30919 B/op 648 allocs/op
BenchmarkTraversalLarge/out/hops=6 2.55ms 757988 B/op 16267 allocs/op
BenchmarkTraversalLarge/both/hops=1 23.6µs 6007 B/op 118 allocs/op
BenchmarkTraversalLarge/both/hops=3 484.2µs 164892 B/op 3498 allocs/op

BenchmarkMatchSimple/nodes=100 4.1µs 1619 B/op 22 allocs/op
BenchmarkMatchSimple/nodes=10000 4.4µs 1619 B/op 22 allocs/op

BenchmarkCreateNode 11.6µs 1679 B/op 36 allocs/op
BenchmarkCreateNodeFile 67.5µs 1686 B/op 35 allocs/op (on disk, WAL)
BenchmarkCreateEdge 7.2µs 714 B/op 17 allocs/op
BenchmarkBulkInsertTx/batch=1000 6.47ms 835897 B/op 29476 allocs/op
```

Key takeaways:
- **Chain traversal** scales linearly with hop depth (~31us at 1 hop to ~176us at 10 hops on a 100-node chain).
- **Fanout traversal** scales with the number of nodes reached; a depth-6 tree with fanout 3 (1093 nodes) takes ~93ms.
- **Dense graph traversal** is the most expensive — on a 500-node graph with 5 edges/node, the reachable set explodes quickly. The `UNION` deduplication in the CTE prevents infinite loops but can't prevent visiting the full reachable set.
- **Simple match** scales linearly with table size (SQLite index scan).
- **Bulk insert** throughput is ~16ms for 1000 nodes+edges in a single transaction (~62k inserts/sec).
- **Traversal cost tracks the reached set**, not the graph size. Each hop is a covering-index lookup, so one hop costs about the same on a 100-node chain and a 10k-node graph.
- **Dense graph traversal** is the most expensive. `UNION` deduplicates on (node, depth), so a node reachable at several depths is expanded once per depth.
- **Result decoding** is a large share of big traversals: each node's JSON properties are unmarshaled into a map. `Return` skips it; returning only `name` from a 6-hop traversal with 10-key properties cuts time from 6.6ms to 1.8ms.

[`bench/compare`](bench/compare) benchmarks against [GraphQLite](https://github.com/colliery-io/graphqlite) on 10k-node random and 100k-node power-law graphs.
- **Name matches** use an index and do not grow with label size.

## License

Expand Down
35 changes: 35 additions & 0 deletions bench/compare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Comparison benchmarks

Benchmarks go-sqlite-graph against [GraphQLite](https://github.com/colliery-io/graphqlite), a SQLite extension with Cypher support. This is a separate module because it needs CGo (`mattn/go-sqlite3`) to load the extension.

Each graph is loaded into three engines, and the harness fails if they disagree on any result size:

| Engine | What runs |
|---|---|
| `ours` | The go-sqlite-graph API on modernc SQLite (pure Go) |
| `ours-sql-on-c` | The SQL go-sqlite-graph generates, run on C SQLite against the same database file |
| `graphqlite` | Variable-length Cypher through `cypher_rows`, with data loaded the way GraphQLite's bulk insert API loads it |

`ours-sql-on-c` separates the library's schema and query design from the cost of the pure-Go SQLite engine.

Graphs:

- `random-10k`: 10k nodes, 3 uniform random outgoing edges per node.
- `powerlaw-100k`: 100k nodes, 500k edges with Zipf-distributed targets. Node 0 is a hub with ~10k in-edges.

Every query starts from a node matched by label and name, and returns the distinct names of reached nodes.

## Running

Get the GraphQLite extension. The PyPI wheel ships a prebuilt library:

```
pip download graphqlite --no-deps --only-binary=:all: -d /tmp/gql
unzip -o /tmp/gql/*.whl -d /tmp/gql
```

Then run:

```
GRAPHQLITE_EXT=/tmp/gql/graphqlite/graphqlite.dylib go test -bench . -benchmem
```
Loading
Loading