Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,20 @@ go.work.sum
.env

cpu_profile.prof

# IDE config
.idea/
.vscode/

# Tool config directories
.osgrep
.opencode
.claude

# Benchmark result files
load_test/*.json

# Built binaries
query
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unscoped .gitignore pattern query will ignore untracked files under existing cmd/query, making future additions easy to miss. Scope the ignore to the repo root if that's the intent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .gitignore, line 27:

<comment>Unscoped `.gitignore` pattern `query` will ignore untracked files under existing `cmd/query`, making future additions easy to miss. Scope the ignore to the repo root if that's the intent.</comment>

<file context>
@@ -24,3 +24,4 @@ cpu_profile.prof
 .osgrep
 .opencode
 .claude
+query
</file context>
Suggested change
query
/query
Fix with Cubic

modusgraphgen
modusgraph-gen
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,69 @@ if err != nil {

These operations are useful for testing or when you need to reset your database state.

## Code Generation

modusGraph includes a code generation tool that reads your Go structs and produces a fully typed
client library with CRUD operations, query builders, auto-paging iterators, functional options, and
an optional CLI.

### Installation

```sh
go install github.com/matthewmcneely/modusgraph/cmd/modusgraphgen@latest
```

### Usage

Add a `go:generate` directive to your package:

```go
//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen
```

Then run:

```sh
go generate ./...
```

### What Gets Generated

| Template | Output | Scope |
|----------|--------|-------|
| client | `client_gen.go` | Once -- typed `Client` with sub-clients per entity |
| page_options | `page_options_gen.go` | Once -- `First(n)` and `Offset(n)` pagination |
| iter | `iter_gen.go` | Once -- auto-paging `SearchIter` and `ListIter` |
| entity | `<entity>_gen.go` | Per entity -- `Get`, `Add`, `Update`, `Delete`, `Search`, `List` |
| options | `<entity>_options_gen.go` | Per entity -- functional options for each scalar field |
| query | `<entity>_query_gen.go` | Per entity -- fluent query builder |
| cli | `cmd/<pkg>/main.go` | Once -- Kong CLI with subcommands per entity |

### Flags

| Flag | Default | Description |
|------|---------|-------------|
| `-pkg` | `.` | Path to the target Go package directory |
| `-output` | same as `-pkg` | Output directory for generated files |
| `-cli-dir` | `{output}/cmd/{package}` | Output directory for CLI main.go |
| `-cli-name` | package name | Name for CLI binary |
| `-with-validator` | `false` | Enable struct validation in generated CLI |

### Entity Detection

A struct is recognized as an entity when it has both of these fields:

```go
UID string `json:"uid,omitempty"`
DType []string `json:"dgraph.type,omitempty"`
```

All other exported fields with `json` and optional `dgraph` struct tags are parsed as entity fields.
Edge relationships are detected when a field type is `[]OtherEntity` where `OtherEntity` is another
struct in the same package. See the
[Defining Your Graph with Structs](#defining-your-graph-with-structs) section above for the full
struct tag reference.

## Limitations

modusGraph has a few limitations to be aware of:
Expand Down
277 changes: 277 additions & 0 deletions cmd/modusgraph-gen/internal/generator/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
// Package generator executes code-generation templates against a parsed model
// to produce typed client libraries. It embeds all templates from the templates/
// directory and writes generated Go source files to the specified output directory.
package generator

import (
"bytes"
"embed"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
"unicode"

"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/model"
)

//go:embed templates/*.tmpl
var templateFS embed.FS

// header is prepended to every generated file.
const header = "// Code generated by modusGraphGen. DO NOT EDIT.\n\n"

// generateConfig holds optional configuration for Generate.
type generateConfig struct {
CLIDir string // Absolute path for CLI output; empty means default ({outputDir}/cmd/{name}).
}

// GenerateOption configures code generation.
type GenerateOption func(*generateConfig)

// WithCLIDir sets the output directory for the generated CLI main.go.
// When empty (the default), the CLI is generated at {outputDir}/cmd/{packageName}/main.go.
func WithCLIDir(dir string) GenerateOption {
return func(c *generateConfig) { c.CLIDir = dir }
}

// Generate renders all code-generation templates against pkg and writes the
// resulting Go source files into outputDir. The directory must already exist.
func Generate(pkg *model.Package, outputDir string, opts ...GenerateOption) error {
cfg := &generateConfig{}
for _, opt := range opts {
opt(cfg)
}

// Default CLIName to the package name if not explicitly set.
if pkg.CLIName == "" {
pkg.CLIName = pkg.Name
}
// Sort entities by name for deterministic output.
sort.Slice(pkg.Entities, func(i, j int) bool {
return pkg.Entities[i].Name < pkg.Entities[j].Name
})
funcMap := template.FuncMap{
"toLower": strings.ToLower,
"toUpper": strings.ToUpper,
"toSnakeCase": toSnakeCase,
"toCamelCase": toCamelCase,
"toLowerCamel": toLowerCamel,
"title": strings.Title, //nolint:staticcheck
"hasPrefix": strings.HasPrefix,
"hasSuffix": strings.HasSuffix,
"contains": strings.Contains,
"trimPrefix": strings.TrimPrefix,
"join": strings.Join,
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },

// Field helpers for templates.
"scalarFields": scalarFields,
"edgeFields": edgeFields,
"searchPredicate": searchPredicate,
"externalImports": externalImports,
}

tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.tmpl")
if err != nil {
return fmt.Errorf("parsing templates: %w", err)
}

// 1. client.go.tmpl → client_gen.go (once)
if err := executeAndWrite(tmpl, "client.go.tmpl", pkg, filepath.Join(outputDir, "client_gen.go")); err != nil {
return err
}

// 2. page_options.go.tmpl → page_options_gen.go (once)
if err := executeAndWrite(tmpl, "page_options.go.tmpl", pkg, filepath.Join(outputDir, "page_options_gen.go")); err != nil {
return err
}

// 3. iter.go.tmpl → iter_gen.go (once)
if err := executeAndWrite(tmpl, "iter.go.tmpl", pkg, filepath.Join(outputDir, "iter_gen.go")); err != nil {
return err
}

// Per-entity templates.
type entityData struct {
PackageName string
Entity model.Entity
Entities []model.Entity
Imports map[string]string // Package alias → import path
}

for _, entity := range pkg.Entities {
data := entityData{
PackageName: pkg.Name,
Entity: entity,
Entities: pkg.Entities,
Imports: pkg.Imports,
}
snake := toSnakeCase(entity.Name)

// 4. entity.go.tmpl → <snake>_gen.go
if err := executeAndWrite(tmpl, "entity.go.tmpl", data, filepath.Join(outputDir, snake+"_gen.go")); err != nil {
return err
}

// 5. options.go.tmpl → <snake>_options_gen.go
if err := executeAndWrite(tmpl, "options.go.tmpl", data, filepath.Join(outputDir, snake+"_options_gen.go")); err != nil {
return err
}

// 6. query.go.tmpl → <snake>_query_gen.go
if err := executeAndWrite(tmpl, "query.go.tmpl", data, filepath.Join(outputDir, snake+"_query_gen.go")); err != nil {
return err
}
}

// 7. cli.go.tmpl → cmd/<name>/main.go (stub)
cliDir := cfg.CLIDir
if cliDir == "" {
cliDir = filepath.Join(outputDir, "cmd", pkg.Name)
}
if err := os.MkdirAll(cliDir, 0o755); err != nil {
return fmt.Errorf("creating CLI directory: %w", err)
}
if err := executeAndWrite(tmpl, "cli.go.tmpl", pkg, filepath.Join(cliDir, "main.go")); err != nil {
return err
}

return nil
}

// executeAndWrite renders a named template and writes the gofmt'd result to path.
func executeAndWrite(tmpl *template.Template, name string, data any, path string) error {
var buf bytes.Buffer
buf.WriteString(header)

if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return fmt.Errorf("executing template %s: %w", name, err)
}

// Format the output with gofmt.
formatted, err := format.Source(buf.Bytes())
if err != nil {
// Write the unformatted output for debugging.
_ = os.WriteFile(path+".broken", buf.Bytes(), 0o644)
return fmt.Errorf("formatting %s: %w\nRaw output written to %s.broken", name, err, path)
}

if err := os.WriteFile(path, formatted, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}

return nil
}

// toSnakeCase converts a Go identifier like "ContentRating" to "content_rating".
func toSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
if unicode.IsUpper(r) {
if i > 0 {
prev := rune(s[i-1])
if unicode.IsLower(prev) || (i+1 < len(s) && unicode.IsLower(rune(s[i+1]))) {
result.WriteRune('_')
}
}
result.WriteRune(unicode.ToLower(r))
} else {
result.WriteRune(r)
}
}
return result.String()
}

// toCamelCase converts a snake_case or lowercase string to CamelCase.
func toCamelCase(s string) string {
parts := strings.Split(s, "_")
var result strings.Builder
for _, p := range parts {
if len(p) > 0 {
result.WriteString(strings.ToUpper(p[:1]))
result.WriteString(p[1:])
}
}
return result.String()
}

// toLowerCamel converts an identifier to lowerCamelCase.
func toLowerCamel(s string) string {
if len(s) == 0 {
return s
}
// If already CamelCase, just lower the first letter.
return strings.ToLower(s[:1]) + s[1:]
}

// scalarFields returns fields that are not UID, DType, or edges.
func scalarFields(fields []model.Field) []model.Field {
var result []model.Field
for _, f := range fields {
if f.IsUID || f.IsDType || f.IsEdge {
continue
}
result = append(result, f)
}
return result
}

// edgeFields returns only edge fields.
func edgeFields(fields []model.Field) []model.Field {
var result []model.Field
for _, f := range fields {
if f.IsEdge {
result = append(result, f)
}
}
return result
}

// externalImports returns a sorted list of import paths needed by the given
// fields. It scans field GoTypes for package-qualified types (containing a dot
// that isn't "time."), looks up the full import path in the imports map, and
// returns the unique set. Standard library packages like "time" are excluded
// because templates handle them separately.
func externalImports(fields []model.Field, imports map[string]string) []string {
seen := make(map[string]bool)
var result []string
for _, f := range fields {
dot := strings.IndexByte(f.GoType, '.')
if dot < 0 {
continue
}
// Extract the package alias (e.g., "enums" from "enums.ResourceType").
pkgAlias := f.GoType[:dot]
// Skip standard library packages that are handled directly in templates.
if pkgAlias == "time" {
continue
}
path, ok := imports[pkgAlias]
if !ok || seen[path] {
continue
}
seen[path] = true
result = append(result, path)
}
sort.Strings(result)
return result
}

// searchPredicate returns the dgraph predicate name for the entity's search
// field, or empty string if not searchable.
func searchPredicate(entity model.Entity) string {
if !entity.Searchable {
return ""
}
for _, f := range entity.Fields {
if f.Name == entity.SearchField {
return f.Predicate
}
}
return ""
}
Loading
Loading