README
¶
modusGraphGen
Code generation tool for modusgraph.
Define Go structs, run go generate, get a fully typed client library, query
builders, auto-paging iterators, and a CLI — all derived from your struct
definitions.
Table of Contents
- Overview
- How to Add to Your Project
- Struct Tags
- Entity Detection
- What Gets Generated
- Generated API
- Flags
- How It Works
- Development
- Reference Project
- License
Overview
What if you could have all the convenience and utility promised by ORM tools but none of the object-relational impedance mismatch pain points?
Traditional ORMs map objects to relational tables and spend enormous effort papering over the fundamental mismatch: joins become lazy-loaded proxies, many-to-many relationships need join tables, hierarchical data gets flattened into rows, and queries are either too abstract (losing performance) or too literal (losing portability). The impedance mismatch isn't a bug in any particular ORM — it's a structural consequence of forcing a graph of objects into a grid of rows and columns.
modusGraphGen sidesteps the problem entirely. Instead of mapping objects to tables, it maps Go structs directly to a graph database (Dgraph) where the data model is a graph of typed nodes and edges. Your struct fields become node predicates. Slice fields pointing to other structs become edges. Reverse edges are first-class. There are no join tables, no N+1 query problems, no lazy-loading surprises.
The workflow is simple:
-
Define your entities as Go structs with
jsonanddgraphtags. Relationships are just typed fields —[]Genreon a Film struct is the edge, not a foreign key pointing somewhere else. -
Run
go generate. modusGraphGen reads your structs at build time and emits a complete, type-safe client library: CRUD operations, fulltext search, a fluent query builder, auto-paging iterators, and a command-line tool — all with concrete Go types, nointerface{}anywhere. -
Use the generated code.
client.Film.Add(ctx, &film)inserts a film with its edges.client.Film.Query(ctx).Filter(...).OrderDesc(...).Exec(&results)builds and runs a query.client.Film.SearchIter(ctx, "Matrix")returns a Go 1.23+ range iterator that auto-pages through results. The CLI gives you the same operations from the terminal.
There are no schema files to maintain separately, no migration scripts, no runtime reflection, and no query language embedded in string literals that only fails at runtime. The struct is the schema. The generated code is the client. Everything is checked at compile time.
For a complete working example with 9 entity types, forward and reverse edges, fulltext search, integration tests, and a CLI operating on Dgraph's 1-million movie dataset, see the modusGraphMoviesProject.
How to Add to Your Project
Prerequisites
- Go 1.24+ — modusGraphGen uses the
tooldirective ingo.mod(introduced in Go 1.24) and the generated iterators userange-over-func (iter.Seq2, introduced in Go 1.23) - Dgraph — either a running Dgraph instance (for
dgraph://connections) or use modusgraph's embedded mode (forfile://connections with no external dependencies)
Step 1: Add modusgraph and modusGraphGen to your module
go get github.com/matthewmcneely/modusgraph
Then add the generator as a tool dependency in your go.mod:
tool github.com/mlwelles/modusGraphGen
Run go mod tidy to fetch everything.
For local development with a cloned copy, add a replace directive:
replace github.com/mlwelles/modusGraphGen => ../modusGraphGen
Step 2: Create a package for your entities
Create a Go package (e.g., movies/) and define your entities as structs. A
struct is recognized as a Dgraph entity when it has both a UID field and a
DType field:
// movies/film.go
package movies
import "time"
type Film struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
InitialReleaseDate time.Time `json:"initialReleaseDate,omitempty" dgraph:"predicate=initial_release_date index=year"`
Tagline string `json:"tagline,omitempty"`
Genres []Genre `json:"genres,omitempty" dgraph:"predicate=genre reverse count"`
}
// movies/genre.go
package movies
type Genre struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
Films []Film `json:"films,omitempty" dgraph:"predicate=~genre reverse"`
}
Relationships are expressed directly: Genres []Genre on Film is a forward
edge. Films []Film with predicate=~genre on Genre is the reverse edge
back. See Struct Tags for the full reference.
Step 3: Add a generate directive
Create a small file that tells go generate to run modusGraphGen:
// movies/generate.go
package movies
//go:generate go run github.com/mlwelles/modusGraphGen
Step 4: Run code generation
go generate ./movies
This produces _gen.go files in your package directory and a CLI at
movies/cmd/movies/main.go. All generated files are clearly marked and can
be gitignored or committed as you prefer.
Step 5: Use the generated client
package main
import (
"context"
"fmt"
"log"
"github.com/matthewmcneely/modusgraph"
"your-module/movies"
)
func main() {
client, err := movies.New("dgraph://localhost:9080",
modusgraph.WithAutoSchema(true),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
ctx := context.Background()
// Add a genre
scifi := &movies.Genre{Name: "Science Fiction"}
client.Genre.Add(ctx, scifi)
// Add a film with an edge to the genre
film := &movies.Film{
Name: "The Matrix",
Tagline: "Welcome to the Real World",
Genres: []movies.Genre{*scifi},
}
client.Film.Add(ctx, film)
fmt.Println(film.UID) // populated by Dgraph
// Search, query, iterate — all typed, all generated
for f, err := range client.Film.SearchIter(ctx, "Matrix") {
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s (%d genres)\n", f.Name, len(f.Genres))
}
}
Step 6 (optional): Build the CLI
go build -o bin/movies ./movies/cmd/movies
./bin/movies film search "Matrix" --first=5
./bin/movies genre list --first=20
Reference implementation
The modusGraphMoviesProject demonstrates the complete workflow: 9 entity types, forward and reverse edges, fulltext search, a Docker Compose setup for Dgraph, a Makefile for data loading, integration tests, and the generated CLI. Use it as a starting point or a reference for how to structure your own project.
Struct Tags
modusGraphGen reads two struct tag systems to understand your data model:
The json Tag
Standard Go JSON serialization tag. modusGraphGen uses it to determine:
- The default predicate name (when no
predicate=is specified indgraph) - Whether the field uses omitempty semantics
Name string `json:"name,omitempty"`
// ^^^^ — predicate defaults to "name"
// ^^^^^^^^^^ — omit from mutations when zero value
The dgraph Tag
Controls how the field maps to Dgraph's schema. Uses space-separated
directives. Commas appear only within index= to separate multiple index types.
// Space separates independent directives:
InitialReleaseDate time.Time `json:"initialReleaseDate,omitempty" dgraph:"predicate=initial_release_date index=year"`
// Commas separate index tokenizers within index=:
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
// Forward edge with reverse indexing and count:
Genres []Genre `json:"genres,omitempty" dgraph:"predicate=genre reverse count"`
// Reverse edge (BOTH ~ prefix AND reverse keyword required):
Films []Film `json:"films,omitempty" dgraph:"predicate=~genre reverse"`
// Standalone flags:
Starring []Performance `json:"starring,omitempty" dgraph:"count"`
Email string `json:"email,omitempty" dgraph:"upsert"`
Tag Directives Reference
| Directive | Example | Effect |
|---|---|---|
predicate=X |
predicate=initial_release_date |
Override the Dgraph predicate name. Default: json tag value |
predicate=~X |
predicate=~genre |
Declare a reverse edge. Must also include reverse |
index=types |
index=hash,term,trigram,fulltext |
Add search indexes (see Index Types below) |
reverse |
reverse |
On forward edges: enables ~predicate queries from the other side. On reverse edges (predicate=~X): required to set dgman's ManagedReverse flag so the edge is expanded in query results |
count |
count |
Enable count(predicate) aggregate queries on this edge |
upsert |
upsert |
Mark field for upsert deduplication (find-or-create by this value) |
unique |
unique |
Enforce uniqueness via dgman's upsert-based insert |
type=X |
type=geo |
Dgraph type hint for non-standard types (geo, password, etc.) |
String Index Types
Dgraph offers several index types for string predicates. Specify one or more
with index= (comma-separated). Each enables different DQL filter functions:
| Index | DQL Functions | Description |
|---|---|---|
hash |
eq |
Fast equality check. Hashes the full string, so efficient for long values. Use when you only need exact match |
exact |
eq, lt, le, gt, ge |
Stores the full string for equality and lexicographic comparison. Use when you need inequality filters on strings |
term |
allofterms, anyofterms |
Splits the string into whitespace-delimited terms. allofterms matches when ALL terms are present; anyofterms matches when ANY term is present |
fulltext |
alloftext, anyoftext |
Full-text search with stemming and stop-word removal. "run" matches "running" and "ran". Supports 18 languages. This is the index that triggers Search() generation |
trigram |
regexp |
Decomposes the string into 3-character substrings (trigrams) for regular expression matching. Efficient when the regex contains long literal substrings |
Combining indexes: You can specify multiple index types on the same field.
For example, a Name field that needs fulltext search, exact match, term
matching, and regex support:
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
Scalar Index Types
Non-string types have their own index options:
| Go Type | Dgraph Type | Index | DQL Functions |
|---|---|---|---|
int, int64 |
int |
(default) | eq, lt, le, gt, ge |
float64 |
float |
(default) | eq, lt, le, gt, ge |
bool |
bool |
(default) | eq |
time.Time |
datetime |
year, month, day, hour |
eq, lt, le, gt, ge at specified granularity |
[]float64 |
geo |
geo (+ type=geo) |
near, within, contains, intersects |
For datetime fields, the index granularity controls the precision:
index=year— filter by year (most common for date ranges)index=month— filter down to monthindex=day— filter down to dayindex=hour— filter down to hour
When predicate= Is Needed
By default, the Dgraph predicate name is the json tag value. Use
predicate= when they differ:
| Scenario | json tag |
predicate= |
Why |
|---|---|---|---|
| Snake case predicate | initialReleaseDate |
initial_release_date |
Dgraph uses snake_case, API uses camelCase |
| Dot-prefixed predicate | films |
director.film |
Namespaced predicate in the dataset |
| Singular vs plural | genres |
genre |
Dgraph predicate is singular, Go field is plural |
| Reverse edge | films |
~genre |
Traverse the genre edge backward |
Forward vs Reverse Edges
Forward edge — Film points to Genre via the genre predicate:
// Film.go
Genres []Genre `json:"genres,omitempty" dgraph:"predicate=genre reverse count"`
// ^^^^^^^^^^^^^^^^ predicate name
// ^^^^^^^ allows ~genre queries
// ^^^^^ enables count()
Reverse edge — Genre discovers which Films point to it:
// Genre.go
Films []Film `json:"films,omitempty" dgraph:"predicate=~genre reverse"`
// ^^^^^^^^^^^^^^^ ~ = reverse direction
// ^^^^^^^ REQUIRED for ManagedReverse
The reverse keyword is required on both sides:
- On the forward edge, it tells Dgraph to maintain a reverse index
- On the reverse edge, it tells dgman to set
ManagedReverse, which causes the reverse edge to be expanded when querying
Complete Struct Example
Here is a comprehensive example showing all tag features:
package movies
import "time"
// Film is a Dgraph entity — has UID + DType fields.
type Film struct {
// Required entity fields
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
// Scalar with multiple string indexes (triggers Search generation via fulltext)
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
// Datetime with custom predicate name and year-granularity index
InitialReleaseDate time.Time `json:"initialReleaseDate,omitempty" dgraph:"predicate=initial_release_date index=year"`
// Plain scalar (no dgraph tag needed — predicate defaults to json tag "tagline")
Tagline string `json:"tagline,omitempty"`
// Forward edge with reverse indexing and count
Genres []Genre `json:"genres,omitempty" dgraph:"predicate=genre reverse count"`
// Forward edge with reverse indexing (no count)
Countries []Country `json:"countries,omitempty" dgraph:"predicate=country reverse"`
}
// Genre is a Dgraph entity with a reverse edge back to Film.
type Genre struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
// Reverse edge — lists Films that have this Genre
Films []Film `json:"films,omitempty" dgraph:"predicate=~genre reverse"`
}
// Director uses a dot-prefixed predicate for its Films edge.
type Director struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
Films []Film `json:"films,omitempty" dgraph:"predicate=director.film reverse count"`
}
// Location demonstrates geo and upsert features.
type Location struct {
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"`
Loc []float64 `json:"loc,omitempty" dgraph:"index=geo type=geo"`
Email string `json:"email,omitempty" dgraph:"index=exact upsert"`
}
Entity Detection
A struct is recognized as a Dgraph entity when it has both of these fields:
UID string `json:"uid,omitempty"` // identifies the node in Dgraph
DType []string `json:"dgraph.type,omitempty"` // Dgraph type discriminator
Structs without both fields are silently ignored by the generator. This lets you define helper structs or value types in the same package without them being treated as entities.
What Gets Generated
For a package with N entity structs, modusGraphGen produces:
| File | Contents |
|---|---|
client_gen.go |
Client struct with a sub-client field per entity, New(connStr, opts...), NewFromClient(conn), Close() |
page_options_gen.go |
PageOption interface, First(n), Offset(n) — shared pagination across all entities |
iter_gen.go |
SearchIter (for entities with fulltext) and ListIter (for all entities) — auto-paging iterators using Go 1.23+ iter.Seq2 |
<entity>_gen.go |
<Entity>Client struct with Get, Add, Update, Delete, Search (if fulltext), List |
<entity>_options_gen.go |
Functional options per entity (reserved for future use) |
<entity>_query_gen.go |
<Entity>Query builder with Filter, OrderAsc, OrderDesc, First, Offset, Exec, ExecAndCount |
cmd/<pkg>/main.go |
Complete Kong CLI with subcommands per entity |
Inference Rules
The generator uses struct tags to decide what to generate:
| Struct characteristic | What gets generated |
|---|---|
Has UID + DType fields |
Recognized as entity — gets <Entity>Client sub-client |
String field with index=fulltext |
Search(ctx, term, opts...) method + SearchIter iterator |
Field typed []OtherEntity |
Recognized as edge relationship (handled by modusgraph at runtime) |
predicate=~X with reverse |
Reverse edge — dgman expands this when querying |
| Every entity (unconditionally) | Get, Add, Update, Delete, List, ListIter, Query builder |
Generated API
Client Setup
import (
"github.com/matthewmcneely/modusgraph"
"your-module/movies"
)
// Connect to Dgraph via gRPC
client, err := movies.New("dgraph://localhost:9080",
modusgraph.WithAutoSchema(true), // auto-create schema from struct tags
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
The Client struct exposes a typed sub-client for every entity:
client.Film // *FilmClient — Get, Add, Update, Delete, Search, List, Query
client.Director // *DirectorClient
client.Genre // *GenreClient
client.Actor // *ActorClient
// ... one per entity
CRUD Operations
Every entity sub-client has Get, Add, Update, and Delete:
ctx := context.Background()
// Add — inserts a new node, populates UID on the struct
film := &movies.Film{
Name: "The Matrix",
InitialReleaseDate: time.Date(1999, 3, 31, 0, 0, 0, 0, time.UTC),
Tagline: "Welcome to the Real World",
Genres: []movies.Genre{action, scifi}, // edges set on insert
}
err := client.Film.Add(ctx, film)
fmt.Println(film.UID) // "0x4e2a" — populated by Dgraph
// Get — retrieves a node by UID with edges expanded
got, err := client.Film.Get(ctx, "0x4e2a")
fmt.Println(got.Name) // "The Matrix"
fmt.Println(len(got.Genres)) // 2 — edges are populated
// Update — modifies the node in place
got.Tagline = "There is no spoon"
err = client.Film.Update(ctx, got)
// Delete — removes the node by UID
err = client.Film.Delete(ctx, "0x4e2a")
Fulltext Search
Generated for entities that have a string field with index=fulltext. Uses
Dgraph's alloftext function which supports stemming ("run" matches
"running", "ran") and stop-word removal:
// Basic search
films, err := client.Film.Search(ctx, "Matrix")
// With pagination
films, err = client.Film.Search(ctx, "Matrix",
movies.First(10), // limit to 10 results
movies.Offset(20), // skip the first 20
)
// Search is generated per-entity — any entity with a fulltext field gets it
directors, err := client.Director.Search(ctx, "Coppola")
actors, err := client.Actor.Search(ctx, "Keanu")
genres, err := client.Genre.Search(ctx, "Action")
List with Pagination
Retrieve entities with cursor-based pagination:
page1, err := client.Film.List(ctx, movies.First(10))
page2, err := client.Film.List(ctx, movies.First(10), movies.Offset(10))
// List all genres
genres, err := client.Genre.List(ctx, movies.First(100))
Query Builder
For complex queries combining filters, ordering, and pagination. The query builder constructs DQL under the hood:
var results []movies.Film
// Filter + order + limit
err := client.Film.Query(ctx).
Filter(`alloftext(name, "Star")`).
OrderAsc("name").
First(5).
Exec(&results)
// Order by date descending
err = client.Film.Query(ctx).
First(10).
OrderDesc("initial_release_date").
Exec(&results)
// Count total matching results
count, err := client.Film.Query(ctx).
Filter(`alloftext(name, "Matrix")`).
First(10).
ExecAndCount(&results)
fmt.Printf("Got %d results out of %d total\n", len(results), count)
Common DQL filter patterns for the Filter method:
// Fulltext search (requires index=fulltext)
Filter(`alloftext(name, "Star Wars")`)
// Term matching (requires index=term)
Filter(`allofterms(name, "Star Wars")`) // both "Star" AND "Wars" present
Filter(`anyofterms(name, "Star Wars")`) // "Star" OR "Wars" present
// Equality (requires index=hash or index=exact)
Filter(`eq(name, "The Matrix")`)
// String inequality (requires index=exact)
Filter(`ge(name, "A") AND le(name, "M")`)
// Date range (requires index=year on datetime field)
Filter(`ge(initial_release_date, "1999-01-01") AND le(initial_release_date, "1999-12-31")`)
// Regular expression (requires index=trigram)
Filter(`regexp(name, /matrix/i)`)
Auto-Paging Iterators
Uses Go 1.23+ range-over-func (iter.Seq2) to iterate through all pages
automatically. Each iteration fetches the next page of 50 results transparently:
// Iterate over all films matching "Star Wars" (auto-pages in batches of 50)
for film, err := range client.Film.SearchIter(ctx, "Star Wars") {
if err != nil {
log.Fatal(err)
}
fmt.Println(film.Name)
}
// Iterate over all genres
for genre, err := range client.Genre.ListIter(ctx) {
if err != nil {
log.Fatal(err)
}
fmt.Println(genre.Name)
}
// Early termination works — just break out of the loop
for film, err := range client.Film.ListIter(ctx) {
if err != nil { break }
if film.Name == "The Matrix" {
fmt.Println("Found it!")
break // stops fetching more pages
}
}
SearchIter is generated only for entities with a fulltext-indexed field.
ListIter is generated for every entity.
Generated CLI
The generated Kong CLI provides subcommands for every entity. Output is JSON
for easy piping to jq:
# Build the CLI
go build -o bin/movies ./movies/cmd/movies
# Search (available for entities with fulltext index)
./bin/movies film search "Matrix" --first=5
./bin/movies director search "Coppola"
./bin/movies actor search "Keanu"
# Get by UID
./bin/movies film get 0x4e2a
# List with pagination
./bin/movies genre list --first=20
./bin/movies film list --first=10 --offset=30
# Add
./bin/movies film add --name="New Film" --tagline="A great film"
./bin/movies genre add --name="Musical"
# Delete
./bin/movies film delete 0x4e2a
# Pipe to jq
./bin/movies film search "Star Wars" | jq '.[].name'
The CLI connects to Dgraph at dgraph://localhost:9080 by default. Override
with --addr or the DGRAPH_ADDR environment variable.
Flags
modusGraphGen [flags]
-pkg string
path to the target Go package directory (default ".")
-output string
output directory (default: same as -pkg)
When invoked via go:generate, the working directory is the package directory,
so the defaults work without flags.
How It Works
modusGraphGen operates in three phases:
-
Parse — Uses
go/astandgo/parserto walk the AST of all.gofiles in the target package. Extracts struct names, field types, andjson/dgraphtags. Builds an intermediatemodel.PackagewithEntityandFieldtypes. -
Infer — Applies inference rules to the parsed model: detects entities (UID + DType), identifies searchable fields (fulltext index), resolves edge relationships (slice of another entity), and marks reverse edges (~ prefix).
-
Generate — Executes Go
text/templatetemplates embedded in the binary viaembed.FS. Each template receives the model and produces a_gen.gofile. The CLI template additionally producescmd/<pkg>/main.go.
Development
make help # show all targets
make build # build the modusGraphGen binary
make test # run tests (requires ../modusGraphMoviesProject)
make check # go vet
make update-golden # regenerate golden test files after template changes
Golden File Tests
The generator tests parse struct definitions from the sibling
modusGraphMoviesProject repository and compare generated output against
checked-in golden files in generator/testdata/golden/. This ensures template
changes don't introduce regressions.
Update golden files after changing templates:
make update-golden
Then review the diff to confirm the changes are intentional.
Reference Project
modusGraphMoviesProject is the reference consumer of modusGraphGen, demonstrating the full workflow with 9 entity types, forward and reverse edges, fulltext search, integration tests, and Dgraph's 1-million movie dataset.
License
Apache 2.0. See LICENSE.
Documentation
¶
Overview ¶
modusGraphGen is a code generation tool that reads Go structs with dgraph struct tags and produces a typed client library, functional options, query builders, and a Kong CLI.
Usage:
go run github.com/mlwelles/modusGraphGen [flags]
When invoked via go:generate (the typical case), it uses the current working directory as the target package.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package generator executes code-generation templates against a parsed model to produce typed client libraries.
|
Package generator executes code-generation templates against a parsed model to produce typed client libraries. |
|
Package model defines the intermediate representation used between the parser and the code generator.
|
Package model defines the intermediate representation used between the parser and the code generator. |
|
Package parser extracts entity and field metadata from Go source files by inspecting struct declarations and their struct tags.
|
Package parser extracts entity and field metadata from Go source files by inspecting struct declarations and their struct tags. |