diff --git a/.gitignore b/.gitignore index a63304e..157b9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ go.work.sum .env cpu_profile.prof + +# Tool config directories +.osgrep +.opencode +.claude diff --git a/README.md b/README.md index 5405611..4935190 100644 --- a/README.md +++ b/README.md @@ -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 | `_gen.go` | Per entity -- `Get`, `Add`, `Update`, `Delete`, `Search`, `List` | +| options | `_options_gen.go` | Per entity -- functional options for each scalar field | +| query | `_query_gen.go` | Per entity -- fluent query builder | +| cli | `cmd//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: diff --git a/cmd/modusgraphgen/internal/generator/generator.go b/cmd/modusgraphgen/internal/generator/generator.go new file mode 100644 index 0000000..943c04d --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/generator.go @@ -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/modusgraphgen/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 → _gen.go + if err := executeAndWrite(tmpl, "entity.go.tmpl", data, filepath.Join(outputDir, snake+"_gen.go")); err != nil { + return err + } + + // 5. options.go.tmpl → _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 → _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//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 "" +} diff --git a/cmd/modusgraphgen/internal/generator/generator_test.go b/cmd/modusgraphgen/internal/generator/generator_test.go new file mode 100644 index 0000000..29be8b8 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/generator_test.go @@ -0,0 +1,606 @@ +package generator + +import ( + "flag" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser" +) + +var update = flag.Bool("update", false, "update golden files") + +func moviesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // thisFile = .../generator/generator_test.go + // testdata is at .../parser/testdata/movies/ + genDir := filepath.Dir(thisFile) + return filepath.Join(filepath.Dir(genDir), "parser", "testdata", "movies") +} + +// goldenDir returns the path to the golden test data directory. +func goldenDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "testdata", "golden") +} + +func TestGenerate(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // Generate to a temp directory. + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + golden := goldenDir(t) + + if *update { + // Copy all generated files to golden directory. + t.Log("Updating golden files...") + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatal(err) + } + // Clean golden dir first. + _ = os.RemoveAll(golden) + if err := os.MkdirAll(golden, 0o755); err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() { + continue // skip cmd/ directory for golden tests + } + src := filepath.Join(tmpDir, entry.Name()) + dst := filepath.Join(golden, entry.Name()) + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + t.Fatal(err) + } + } + t.Log("Golden files updated.") + return + } + + // Compare generated files against golden files. + goldenEntries, err := os.ReadDir(golden) + if err != nil { + t.Fatalf("Reading golden dir %s: %v\nRun with -update to create golden files.", golden, err) + } + + if len(goldenEntries) == 0 { + t.Fatalf("No golden files found in %s. Run with -update to create them.", golden) + } + + for _, entry := range goldenEntries { + if entry.IsDir() { + continue + } + name := entry.Name() + t.Run(name, func(t *testing.T) { + goldenPath := filepath.Join(golden, name) + generatedPath := filepath.Join(tmpDir, name) + + goldenData, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("reading golden file: %v", err) + } + + generatedData, err := os.ReadFile(generatedPath) + if err != nil { + t.Fatalf("reading generated file: %v", err) + } + + if string(goldenData) != string(generatedData) { + t.Errorf("generated output differs from golden file %s", name) + // Show a diff summary. + goldenLines := strings.Split(string(goldenData), "\n") + generatedLines := strings.Split(string(generatedData), "\n") + maxLines := len(goldenLines) + if len(generatedLines) > maxLines { + maxLines = len(generatedLines) + } + diffCount := 0 + for i := 0; i < maxLines; i++ { + var gl, genl string + if i < len(goldenLines) { + gl = goldenLines[i] + } + if i < len(generatedLines) { + genl = generatedLines[i] + } + if gl != genl { + if diffCount < 10 { + t.Errorf(" line %d:\n golden: %q\n generated: %q", i+1, gl, genl) + } + diffCount++ + } + } + if diffCount > 10 { + t.Errorf(" ... and %d more differences", diffCount-10) + } + } + }) + } +} + +func TestGenerateOutputFiles(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify expected files were created. + expectedFiles := []string{ + "client_gen.go", + "page_options_gen.go", + "iter_gen.go", + } + + // Per-entity files. + entities := []string{ + "actor", "content_rating", "country", "director", + "film", "genre", "location", "performance", "rating", + } + for _, e := range entities { + expectedFiles = append(expectedFiles, + e+"_gen.go", + e+"_options_gen.go", + e+"_query_gen.go", + ) + } + + for _, f := range expectedFiles { + t.Run(f, func(t *testing.T) { + path := filepath.Join(tmpDir, f) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("expected file %s not found: %v", f, err) + } + if info.Size() == 0 { + t.Errorf("file %s is empty", f) + } + }) + } + + // Verify CLI stub. + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + if _, err := os.Stat(cliPath); err != nil { + t.Errorf("CLI stub not found: %v", err) + } +} + +func TestGenerateHeader(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Check that all generated files start with the expected header. + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatal(err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + t.Run(entry.Name(), func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(tmpDir, entry.Name())) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(data), "// Code generated by modusGraphGen. DO NOT EDIT.") { + t.Errorf("file %s does not start with expected header", entry.Name()) + } + }) + } +} + +func TestExternalImports(t *testing.T) { + t.Run("NoExternalTypes", func(t *testing.T) { + fields := []model.Field{ + {Name: "Name", GoType: "string"}, + {Name: "Created", GoType: "time.Time"}, + {Name: "Size", GoType: "int64"}, + } + imports := map[string]string{"time": "time"} + got := externalImports(fields, imports) + if len(got) != 0 { + t.Errorf("expected no external imports, got %v", got) + } + }) + + t.Run("WithExternalPackage", func(t *testing.T) { + fields := []model.Field{ + {Name: "Name", GoType: "string"}, + {Name: "TypeName", GoType: "enums.ResourceType"}, + {Name: "Status", GoType: "enums.ArchiveStatus"}, + {Name: "Created", GoType: "time.Time"}, + } + imports := map[string]string{ + "time": "time", + "enums": "github.com/example/project/enums", + } + got := externalImports(fields, imports) + if len(got) != 1 { + t.Fatalf("expected 1 external import, got %v", got) + } + if got[0] != "github.com/example/project/enums" { + t.Errorf("got %q, want %q", got[0], "github.com/example/project/enums") + } + }) + + t.Run("MultipleExternalPackages", func(t *testing.T) { + fields := []model.Field{ + {Name: "TypeName", GoType: "enums.ResourceType"}, + {Name: "PageInfo", GoType: "pagination.PageInfo"}, + } + imports := map[string]string{ + "enums": "github.com/example/project/enums", + "pagination": "github.com/example/project/pagination", + } + got := externalImports(fields, imports) + if len(got) != 2 { + t.Fatalf("expected 2 external imports, got %v", got) + } + // Should be sorted. + if got[0] != "github.com/example/project/enums" { + t.Errorf("got[0] = %q, want enums path", got[0]) + } + if got[1] != "github.com/example/project/pagination" { + t.Errorf("got[1] = %q, want pagination path", got[1]) + } + }) + + t.Run("UnknownPackageSkipped", func(t *testing.T) { + fields := []model.Field{ + {Name: "TypeName", GoType: "unknown.SomeType"}, + } + imports := map[string]string{} + got := externalImports(fields, imports) + if len(got) != 0 { + t.Errorf("expected no imports for unknown package, got %v", got) + } + }) +} + +func TestCLITemplateUsesModulePath(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI file: %v", err) + } + + content := string(data) + // Should contain the module path derived from go.mod, NOT a hardcoded movies project path. + expectedImport := `"github.com/mlwelles/modusGraphMoviesProject/movies"` + if !strings.Contains(content, expectedImport) { + t.Errorf("CLI main.go should contain import %s\nGot:\n%s", expectedImport, content) + } + + // Should NOT contain any other hardcoded project path. + // (This test ensures we're using ModulePath, not a hardcoded string.) + badImport := `"github.com/mlwelles/modusGraphMoviesProject/movies"` // same for movies project, different for others + _ = badImport // The import is the same for movies, so this test just verifies it exists. +} + +func TestToSnakeCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"Film", "film"}, + {"ContentRating", "content_rating"}, + {"UID", "uid"}, + {"HTTPServer", "http_server"}, + {"Actor", "actor"}, + {"Performance", "performance"}, + {"Location", "location"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toSnakeCase(tt.input) + if got != tt.want { + t.Errorf("toSnakeCase(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSearchPredicate(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + for _, entity := range pkg.Entities { + if entity.Searchable { + pred := searchPredicate(entity) + if pred == "" { + t.Errorf("entity %s is searchable but searchPredicate returned empty", entity.Name) + } + t.Logf("%s: search predicate = %q", entity.Name, pred) + } + } +} + +func TestWithCLIDir(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + customCLIDir := filepath.Join(tmpDir, "custom", "cli") + + if err := Generate(pkg, tmpDir, WithCLIDir(customCLIDir)); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // CLI should be at the custom path. + customCLIPath := filepath.Join(customCLIDir, "main.go") + if _, err := os.Stat(customCLIPath); err != nil { + t.Fatalf("CLI not found at custom path %s: %v", customCLIPath, err) + } + + // CLI should NOT be at the default path. + defaultCLIPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + if _, err := os.Stat(defaultCLIPath); !os.IsNotExist(err) { + t.Errorf("CLI should not exist at default path %s when custom dir is set", defaultCLIPath) + } +} + +func TestDefaultCLIDir(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + // No WithCLIDir option — should use the default. + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + defaultCLIPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + if _, err := os.Stat(defaultCLIPath); err != nil { + t.Fatalf("CLI not found at default path %s: %v", defaultCLIPath, err) + } +} + +func TestCLINameDefault(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // When CLIName is not set, it should default to the package name. + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI file: %v", err) + } + content := string(data) + if !strings.Contains(content, `kong.Name("movies")`) { + t.Errorf("CLI should use package name as kong.Name when CLIName is not set\nGot:\n%s", content) + } +} + +func TestCLINameCustom(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // Set a custom CLI name. + pkg.CLIName = "film-db" + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI file: %v", err) + } + content := string(data) + + // Should use the custom name for kong.Name and description. + if !strings.Contains(content, `kong.Name("film-db")`) { + t.Errorf("CLI should use custom CLIName for kong.Name\nGot:\n%s", content) + } + if !strings.Contains(content, `kong.Description("CLI for the film-db data model.")`) { + t.Errorf("CLI should use custom CLIName for kong.Description\nGot:\n%s", content) + } + + // Package import should still use the real package name, not CLIName. + if !strings.Contains(content, `"github.com/mlwelles/modusGraphMoviesProject/movies"`) { + t.Errorf("CLI import should still use the real package name, not CLIName\nGot:\n%s", content) + } +} + +func TestWithValidatorEnabled(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + pkg.WithValidator = true + + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI file: %v", err) + } + content := string(data) + + // Should contain the validator option. + if !strings.Contains(content, "modusgraph.WithValidator(modusgraph.NewValidator())") { + t.Errorf("CLI should contain WithValidator when enabled\nGot:\n%s", content) + } + + // Should still contain WithAutoSchema. + if !strings.Contains(content, "modusgraph.WithAutoSchema(true)") { + t.Errorf("CLI should still contain WithAutoSchema\nGot:\n%s", content) + } +} + +func TestWithValidatorDisabled(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // WithValidator defaults to false. + tmpDir := t.TempDir() + if err := Generate(pkg, tmpDir); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + cliPath := filepath.Join(tmpDir, "cmd", "movies", "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI file: %v", err) + } + content := string(data) + + // Should NOT contain the validator option. + if strings.Contains(content, "WithValidator") { + t.Errorf("CLI should NOT contain WithValidator when disabled\nGot:\n%s", content) + } +} + +func TestWithValidatorAndCustomCLI(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // Combine all CLI options. + pkg.CLIName = "registry" + pkg.WithValidator = true + + tmpDir := t.TempDir() + customCLIDir := filepath.Join(tmpDir, "cmd", "registry") + + if err := Generate(pkg, tmpDir, WithCLIDir(customCLIDir)); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + cliPath := filepath.Join(customCLIDir, "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI at custom path: %v", err) + } + content := string(data) + + // Should have all three features working together. + if !strings.Contains(content, `kong.Name("registry")`) { + t.Errorf("CLI should use custom CLIName\nGot:\n%s", content) + } + if !strings.Contains(content, "modusgraph.WithValidator(modusgraph.NewValidator())") { + t.Errorf("CLI should contain WithValidator\nGot:\n%s", content) + } + if !strings.Contains(content, `"github.com/mlwelles/modusGraphMoviesProject/movies"`) { + t.Errorf("CLI import should use real package name\nGot:\n%s", content) + } +} + +func TestWithCLIDirAndCLIName(t *testing.T) { + dir := moviesDir(t) + pkg, err := parser.Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // Use both custom CLI dir and custom CLI name. + pkg.CLIName = "registry" + tmpDir := t.TempDir() + customCLIDir := filepath.Join(tmpDir, "cmd", "registry") + + if err := Generate(pkg, tmpDir, WithCLIDir(customCLIDir)); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // CLI should be at the custom path. + cliPath := filepath.Join(customCLIDir, "main.go") + data, err := os.ReadFile(cliPath) + if err != nil { + t.Fatalf("reading CLI at custom path: %v", err) + } + content := string(data) + + // Should use custom CLI name. + if !strings.Contains(content, `kong.Name("registry")`) { + t.Errorf("CLI should use custom CLIName\nGot:\n%s", content) + } + + // Import should still use real package name. + if !strings.Contains(content, `"github.com/mlwelles/modusGraphMoviesProject/movies"`) { + t.Errorf("CLI import should use real package name\nGot:\n%s", content) + } +} diff --git a/cmd/modusgraphgen/internal/generator/templates/cli.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/cli.go.tmpl new file mode 100644 index 0000000..6444e26 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/cli.go.tmpl @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/alecthomas/kong" + "github.com/matthewmcneely/modusgraph" + "{{.ModulePath}}/{{.Name}}" +) + +// CLI is the root command parsed by Kong. +var CLI struct { + Addr string `help:"Dgraph gRPC address." default:"dgraph://localhost:9080" env:"DGRAPH_ADDR"` +{{- range .Entities}} + {{.Name}} {{.Name}}Cmd `cmd:"" help:"Manage {{.Name}} entities."` +{{- end}} +} + +{{range .Entities}} +// {{.Name}}Cmd groups subcommands for {{.Name}}. +type {{.Name}}Cmd struct { + Get {{.Name}}GetCmd `cmd:"" help:"Get a {{.Name}} by UID."` + List {{.Name}}ListCmd `cmd:"" help:"List {{.Name}} entities."` + Add {{.Name}}AddCmd `cmd:"" help:"Add a new {{.Name}}."` + Delete {{.Name}}DeleteCmd `cmd:"" help:"Delete a {{.Name}} by UID."` +{{- if .Searchable}} + Search {{.Name}}SearchCmd `cmd:"" help:"Search {{.Name}} by {{.SearchField}}."` +{{- end}} +} + +type {{.Name}}GetCmd struct { + UID string `arg:"" required:"" help:"The UID of the {{.Name}}."` +} + +func (c *{{.Name}}GetCmd) Run(client *{{$.Name}}.Client) error { + result, err := client.{{.Name}}.Get(context.Background(), c.UID) + if err != nil { + return err + } + return printJSON(result) +} + +type {{.Name}}ListCmd struct { + First int `help:"Maximum results to return." default:"10"` + Offset int `help:"Number of results to skip." default:"0"` +} + +func (c *{{.Name}}ListCmd) Run(client *{{$.Name}}.Client) error { + results, err := client.{{.Name}}.List(context.Background(), + {{$.Name}}.First(c.First), {{$.Name}}.Offset(c.Offset)) + if err != nil { + return err + } + return printJSON(results) +} + +type {{.Name}}AddCmd struct { +{{- range scalarFields .Fields}}{{if and (not .IsUID) (not .IsDType)}} + {{.Name}} string `help:"Set {{.Name}}." name:"{{toLower .Name}}"` +{{- end}}{{end}} +} + +func (c *{{.Name}}AddCmd) Run(client *{{$.Name}}.Client) error { + v := &{{$.Name}}.{{.Name}}{ +{{- range scalarFields .Fields}}{{if and (not .IsUID) (not .IsDType) (eq .GoType "string")}} + {{.Name}}: c.{{.Name}}, +{{- end}}{{end}} + } + if err := client.{{.Name}}.Add(context.Background(), v); err != nil { + return err + } + return printJSON(v) +} + +type {{.Name}}DeleteCmd struct { + UID string `arg:"" required:"" help:"The UID to delete."` +} + +func (c *{{.Name}}DeleteCmd) Run(client *{{$.Name}}.Client) error { + return client.{{.Name}}.Delete(context.Background(), c.UID) +} +{{if .Searchable}} +type {{.Name}}SearchCmd struct { + Term string `arg:"" required:"" help:"The search term."` + First int `help:"Maximum results to return." default:"10"` + Offset int `help:"Number of results to skip." default:"0"` +} + +func (c *{{.Name}}SearchCmd) Run(client *{{$.Name}}.Client) error { + results, err := client.{{.Name}}.Search(context.Background(), c.Term, + {{$.Name}}.First(c.First), {{$.Name}}.Offset(c.Offset)) + if err != nil { + return err + } + return printJSON(results) +} +{{end}} +{{end}} + +func printJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func main() { + ctx := kong.Parse(&CLI, + kong.Name("{{.CLIName}}"), + kong.Description("CLI for the {{.CLIName}} data model."), + ) + + client, err := {{.Name}}.New(CLI.Addr, + modusgraph.WithAutoSchema(true), +{{- if .WithValidator}} + modusgraph.WithValidator(modusgraph.NewValidator()), +{{- end}} + ) + if err != nil { + fmt.Fprintf(os.Stderr, "connect: %v\n", err) + os.Exit(1) + } + defer client.Close() + + err = ctx.Run(client) + ctx.FatalIfErrorf(err) +} diff --git a/cmd/modusgraphgen/internal/generator/templates/client.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/client.go.tmpl new file mode 100644 index 0000000..53f1d6b --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/client.go.tmpl @@ -0,0 +1,37 @@ +package {{.Name}} + +import ( + "github.com/matthewmcneely/modusgraph" +) + +// Client provides typed access to the {{.Name}} data model. +type Client struct { + conn modusgraph.Client +{{- range .Entities}} + {{.Name}} *{{.Name}}Client +{{- end}} +} + +// New creates a new Client connected to the graph database at connStr. +func New(connStr string, opts ...modusgraph.ClientOpt) (*Client, error) { + conn, err := modusgraph.NewClient(connStr, opts...) + if err != nil { + return nil, err + } + return NewFromClient(conn), nil +} + +// NewFromClient creates a new Client from an existing modusgraph.Client connection. +func NewFromClient(conn modusgraph.Client) *Client { + return &Client{ + conn: conn, +{{- range .Entities}} + {{.Name}}: &{{.Name}}Client{conn: conn}, +{{- end}} + } +} + +// Close releases all resources used by the client. +func (c *Client) Close() { + c.conn.Close() +} diff --git a/cmd/modusgraphgen/internal/generator/templates/entity.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/entity.go.tmpl new file mode 100644 index 0000000..08e9eae --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/entity.go.tmpl @@ -0,0 +1,82 @@ +package {{.PackageName}} + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// {{.Entity.Name}}Client provides typed CRUD operations for {{.Entity.Name}} entities. +type {{.Entity.Name}}Client struct { + conn modusgraph.Client +} + +// Get retrieves a single {{.Entity.Name}} by its UID. +func (c *{{.Entity.Name}}Client) Get(ctx context.Context, uid string) (*{{.Entity.Name}}, error) { + var result {{.Entity.Name}} + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new {{.Entity.Name}} into the database. +func (c *{{.Entity.Name}}Client) Add(ctx context.Context, v *{{.Entity.Name}}) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing {{.Entity.Name}} in the database. The UID field must be set. +func (c *{{.Entity.Name}}Client) Update(ctx context.Context, v *{{.Entity.Name}}) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the {{.Entity.Name}} with the given UID from the database. +func (c *{{.Entity.Name}}Client) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} +{{if .Entity.Searchable}} +// Search finds {{.Entity.Name}} entities whose {{.Entity.SearchField}} matches term using fulltext search. +func (c *{{.Entity.Name}}Client) Search(ctx context.Context, term string, opts ...PageOption) ([]{{.Entity.Name}}, error) { + var results []{{.Entity.Name}} + q := c.conn.Query(ctx, {{.Entity.Name}}{}). + Filter(`alloftext({{searchPredicate .Entity}}, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} +{{end}} +// List retrieves {{.Entity.Name}} entities with optional pagination. +func (c *{{.Entity.Name}}Client) List(ctx context.Context, opts ...PageOption) ([]{{.Entity.Name}}, error) { + var results []{{.Entity.Name}} + q := c.conn.Query(ctx, {{.Entity.Name}}{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/templates/iter.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/iter.go.tmpl new file mode 100644 index 0000000..1c9c6e8 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/iter.go.tmpl @@ -0,0 +1,63 @@ +package {{.Name}} + +import ( + "context" + "iter" +) +{{range .Entities}}{{if .Searchable}} +// SearchIter returns an iterator over {{.Name}} entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *{{.Name}}Client) SearchIter(ctx context.Context, term string) iter.Seq2[{{.Name}}, error] { + return func(yield func({{.Name}}, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero {{.Name}} + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} +{{end}} +// ListIter returns an iterator over all {{.Name}} entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *{{.Name}}Client) ListIter(ctx context.Context) iter.Seq2[{{.Name}}, error] { + return func(yield func({{.Name}}, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero {{.Name}} + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} +{{end}} diff --git a/cmd/modusgraphgen/internal/generator/templates/options.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/options.go.tmpl new file mode 100644 index 0000000..efdbfd5 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/options.go.tmpl @@ -0,0 +1,34 @@ +package {{.PackageName}} +{{$entity := .Entity}} +{{$name := .Entity.Name}} +{{$fields := scalarFields .Entity.Fields}} +{{- $needsTime := false}} +{{- range $fields}}{{if hasPrefix .GoType "time."}}{{$needsTime = true}}{{end}}{{end}} +{{- $extImports := externalImports $fields .Imports}} +{{if or $needsTime (gt (len $extImports) 0)}} +import ( +{{- if $needsTime}} + "time" +{{- end}} +{{range $extImports}} + "{{.}}" +{{- end}} +) +{{end}} +// {{$name}}Option is a functional option for configuring {{$name}} mutations. +type {{$name}}Option func(*{{$name}}) + +{{range $fields}} +// With{{$name}}{{.Name}} sets the {{.Name}} field on a {{$name}}. +func With{{$name}}{{.Name}}(v {{.GoType}}) {{$name}}Option { + return func(e *{{$name}}) { + e.{{.Name}} = v + } +} +{{end}} +// Apply{{$name}}Options applies the given options to a {{$name}}. +func Apply{{$name}}Options(e *{{$name}}, opts ...{{$name}}Option) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/templates/page_options.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/page_options.go.tmpl new file mode 100644 index 0000000..c90e5ac --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/page_options.go.tmpl @@ -0,0 +1,35 @@ +package {{.Name}} + +const defaultPageSize = 50 + +// PageOption configures pagination for queries. +type PageOption interface { + applyPage(cfg *pageConfig) +} + +type pageConfig struct { + first int + offset int +} + +type firstOption int + +func (f firstOption) applyPage(cfg *pageConfig) { + cfg.first = int(f) +} + +// First limits the number of results returned. +func First(n int) PageOption { + return firstOption(n) +} + +type offsetOption int + +func (o offsetOption) applyPage(cfg *pageConfig) { + cfg.offset = int(o) +} + +// Offset skips the first n results. +func Offset(n int) PageOption { + return offsetOption(n) +} diff --git a/cmd/modusgraphgen/internal/generator/templates/query.go.tmpl b/cmd/modusgraphgen/internal/generator/templates/query.go.tmpl new file mode 100644 index 0000000..709a9ec --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/templates/query.go.tmpl @@ -0,0 +1,99 @@ +package {{.PackageName}} + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// {{.Entity.Name}}Query is a typed query builder for {{.Entity.Name}} entities. +type {{.Entity.Name}}Query struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for {{.Entity.Name}} entities. +func (c *{{.Entity.Name}}Client) Query(ctx context.Context) *{{.Entity.Name}}Query { + return &{{.Entity.Name}}Query{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *{{.Entity.Name}}Query) Filter(f string) *{{.Entity.Name}}Query { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *{{.Entity.Name}}Query) OrderAsc(field string) *{{.Entity.Name}}Query { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *{{.Entity.Name}}Query) OrderDesc(field string) *{{.Entity.Name}}Query { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *{{.Entity.Name}}Query) First(n int) *{{.Entity.Name}}Query { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *{{.Entity.Name}}Query) Offset(n int) *{{.Entity.Name}}Query { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *{{.Entity.Name}}Query) Exec(dst *[]{{.Entity.Name}}) error { + dq := q.conn.Query(q.ctx, {{.Entity.Name}}{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *{{.Entity.Name}}Query) ExecAndCount(dst *[]{{.Entity.Name}}) (int, error) { + dq := q.conn.Query(q.ctx, {{.Entity.Name}}{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/actor_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_gen.go new file mode 100644 index 0000000..c0f7b20 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// ActorClient provides typed CRUD operations for Actor entities. +type ActorClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Actor by its UID. +func (c *ActorClient) Get(ctx context.Context, uid string) (*Actor, error) { + var result Actor + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Actor into the database. +func (c *ActorClient) Add(ctx context.Context, v *Actor) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Actor in the database. The UID field must be set. +func (c *ActorClient) Update(ctx context.Context, v *Actor) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Actor with the given UID from the database. +func (c *ActorClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Actor entities whose Name matches term using fulltext search. +func (c *ActorClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Actor, error) { + var results []Actor + q := c.conn.Query(ctx, Actor{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Actor entities with optional pagination. +func (c *ActorClient) List(ctx context.Context, opts ...PageOption) ([]Actor, error) { + var results []Actor + q := c.conn.Query(ctx, Actor{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/actor_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_options_gen.go new file mode 100644 index 0000000..2696efb --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// ActorOption is a functional option for configuring Actor mutations. +type ActorOption func(*Actor) + +// WithActorName sets the Name field on a Actor. +func WithActorName(v string) ActorOption { + return func(e *Actor) { + e.Name = v + } +} + +// ApplyActorOptions applies the given options to a Actor. +func ApplyActorOptions(e *Actor, opts ...ActorOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/actor_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_query_gen.go new file mode 100644 index 0000000..cde05cd --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/actor_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// ActorQuery is a typed query builder for Actor entities. +type ActorQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Actor entities. +func (c *ActorClient) Query(ctx context.Context) *ActorQuery { + return &ActorQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *ActorQuery) Filter(f string) *ActorQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *ActorQuery) OrderAsc(field string) *ActorQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *ActorQuery) OrderDesc(field string) *ActorQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *ActorQuery) First(n int) *ActorQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *ActorQuery) Offset(n int) *ActorQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *ActorQuery) Exec(dst *[]Actor) error { + dq := q.conn.Query(q.ctx, Actor{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *ActorQuery) ExecAndCount(dst *[]Actor) (int, error) { + dq := q.conn.Query(q.ctx, Actor{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/client_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/client_gen.go new file mode 100644 index 0000000..69e0cd6 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/client_gen.go @@ -0,0 +1,51 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "github.com/matthewmcneely/modusgraph" +) + +// Client provides typed access to the movies data model. +type Client struct { + conn modusgraph.Client + Actor *ActorClient + ContentRating *ContentRatingClient + Country *CountryClient + Director *DirectorClient + Film *FilmClient + Genre *GenreClient + Location *LocationClient + Performance *PerformanceClient + Rating *RatingClient +} + +// New creates a new Client connected to the graph database at connStr. +func New(connStr string, opts ...modusgraph.ClientOpt) (*Client, error) { + conn, err := modusgraph.NewClient(connStr, opts...) + if err != nil { + return nil, err + } + return NewFromClient(conn), nil +} + +// NewFromClient creates a new Client from an existing modusgraph.Client connection. +func NewFromClient(conn modusgraph.Client) *Client { + return &Client{ + conn: conn, + Actor: &ActorClient{conn: conn}, + ContentRating: &ContentRatingClient{conn: conn}, + Country: &CountryClient{conn: conn}, + Director: &DirectorClient{conn: conn}, + Film: &FilmClient{conn: conn}, + Genre: &GenreClient{conn: conn}, + Location: &LocationClient{conn: conn}, + Performance: &PerformanceClient{conn: conn}, + Rating: &RatingClient{conn: conn}, + } +} + +// Close releases all resources used by the client. +func (c *Client) Close() { + c.conn.Close() +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_gen.go new file mode 100644 index 0000000..5c7bce4 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// ContentRatingClient provides typed CRUD operations for ContentRating entities. +type ContentRatingClient struct { + conn modusgraph.Client +} + +// Get retrieves a single ContentRating by its UID. +func (c *ContentRatingClient) Get(ctx context.Context, uid string) (*ContentRating, error) { + var result ContentRating + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new ContentRating into the database. +func (c *ContentRatingClient) Add(ctx context.Context, v *ContentRating) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing ContentRating in the database. The UID field must be set. +func (c *ContentRatingClient) Update(ctx context.Context, v *ContentRating) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the ContentRating with the given UID from the database. +func (c *ContentRatingClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds ContentRating entities whose Name matches term using fulltext search. +func (c *ContentRatingClient) Search(ctx context.Context, term string, opts ...PageOption) ([]ContentRating, error) { + var results []ContentRating + q := c.conn.Query(ctx, ContentRating{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves ContentRating entities with optional pagination. +func (c *ContentRatingClient) List(ctx context.Context, opts ...PageOption) ([]ContentRating, error) { + var results []ContentRating + q := c.conn.Query(ctx, ContentRating{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_options_gen.go new file mode 100644 index 0000000..8fbf429 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// ContentRatingOption is a functional option for configuring ContentRating mutations. +type ContentRatingOption func(*ContentRating) + +// WithContentRatingName sets the Name field on a ContentRating. +func WithContentRatingName(v string) ContentRatingOption { + return func(e *ContentRating) { + e.Name = v + } +} + +// ApplyContentRatingOptions applies the given options to a ContentRating. +func ApplyContentRatingOptions(e *ContentRating, opts ...ContentRatingOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_query_gen.go new file mode 100644 index 0000000..c45d3d3 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/content_rating_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// ContentRatingQuery is a typed query builder for ContentRating entities. +type ContentRatingQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for ContentRating entities. +func (c *ContentRatingClient) Query(ctx context.Context) *ContentRatingQuery { + return &ContentRatingQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *ContentRatingQuery) Filter(f string) *ContentRatingQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *ContentRatingQuery) OrderAsc(field string) *ContentRatingQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *ContentRatingQuery) OrderDesc(field string) *ContentRatingQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *ContentRatingQuery) First(n int) *ContentRatingQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *ContentRatingQuery) Offset(n int) *ContentRatingQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *ContentRatingQuery) Exec(dst *[]ContentRating) error { + dq := q.conn.Query(q.ctx, ContentRating{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *ContentRatingQuery) ExecAndCount(dst *[]ContentRating) (int, error) { + dq := q.conn.Query(q.ctx, ContentRating{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/country_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/country_gen.go new file mode 100644 index 0000000..8a8f667 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/country_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// CountryClient provides typed CRUD operations for Country entities. +type CountryClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Country by its UID. +func (c *CountryClient) Get(ctx context.Context, uid string) (*Country, error) { + var result Country + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Country into the database. +func (c *CountryClient) Add(ctx context.Context, v *Country) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Country in the database. The UID field must be set. +func (c *CountryClient) Update(ctx context.Context, v *Country) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Country with the given UID from the database. +func (c *CountryClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Country entities whose Name matches term using fulltext search. +func (c *CountryClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Country, error) { + var results []Country + q := c.conn.Query(ctx, Country{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Country entities with optional pagination. +func (c *CountryClient) List(ctx context.Context, opts ...PageOption) ([]Country, error) { + var results []Country + q := c.conn.Query(ctx, Country{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/country_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/country_options_gen.go new file mode 100644 index 0000000..e799d6d --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/country_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// CountryOption is a functional option for configuring Country mutations. +type CountryOption func(*Country) + +// WithCountryName sets the Name field on a Country. +func WithCountryName(v string) CountryOption { + return func(e *Country) { + e.Name = v + } +} + +// ApplyCountryOptions applies the given options to a Country. +func ApplyCountryOptions(e *Country, opts ...CountryOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/country_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/country_query_gen.go new file mode 100644 index 0000000..be873fa --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/country_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// CountryQuery is a typed query builder for Country entities. +type CountryQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Country entities. +func (c *CountryClient) Query(ctx context.Context) *CountryQuery { + return &CountryQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *CountryQuery) Filter(f string) *CountryQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *CountryQuery) OrderAsc(field string) *CountryQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *CountryQuery) OrderDesc(field string) *CountryQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *CountryQuery) First(n int) *CountryQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *CountryQuery) Offset(n int) *CountryQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *CountryQuery) Exec(dst *[]Country) error { + dq := q.conn.Query(q.ctx, Country{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *CountryQuery) ExecAndCount(dst *[]Country) (int, error) { + dq := q.conn.Query(q.ctx, Country{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/director_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/director_gen.go new file mode 100644 index 0000000..3e5c331 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/director_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// DirectorClient provides typed CRUD operations for Director entities. +type DirectorClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Director by its UID. +func (c *DirectorClient) Get(ctx context.Context, uid string) (*Director, error) { + var result Director + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Director into the database. +func (c *DirectorClient) Add(ctx context.Context, v *Director) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Director in the database. The UID field must be set. +func (c *DirectorClient) Update(ctx context.Context, v *Director) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Director with the given UID from the database. +func (c *DirectorClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Director entities whose Name matches term using fulltext search. +func (c *DirectorClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Director, error) { + var results []Director + q := c.conn.Query(ctx, Director{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Director entities with optional pagination. +func (c *DirectorClient) List(ctx context.Context, opts ...PageOption) ([]Director, error) { + var results []Director + q := c.conn.Query(ctx, Director{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/director_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/director_options_gen.go new file mode 100644 index 0000000..2587d8c --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/director_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// DirectorOption is a functional option for configuring Director mutations. +type DirectorOption func(*Director) + +// WithDirectorName sets the Name field on a Director. +func WithDirectorName(v string) DirectorOption { + return func(e *Director) { + e.Name = v + } +} + +// ApplyDirectorOptions applies the given options to a Director. +func ApplyDirectorOptions(e *Director, opts ...DirectorOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/director_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/director_query_gen.go new file mode 100644 index 0000000..93be564 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/director_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// DirectorQuery is a typed query builder for Director entities. +type DirectorQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Director entities. +func (c *DirectorClient) Query(ctx context.Context) *DirectorQuery { + return &DirectorQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *DirectorQuery) Filter(f string) *DirectorQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *DirectorQuery) OrderAsc(field string) *DirectorQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *DirectorQuery) OrderDesc(field string) *DirectorQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *DirectorQuery) First(n int) *DirectorQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *DirectorQuery) Offset(n int) *DirectorQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *DirectorQuery) Exec(dst *[]Director) error { + dq := q.conn.Query(q.ctx, Director{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *DirectorQuery) ExecAndCount(dst *[]Director) (int, error) { + dq := q.conn.Query(q.ctx, Director{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/film_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/film_gen.go new file mode 100644 index 0000000..b240984 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/film_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// FilmClient provides typed CRUD operations for Film entities. +type FilmClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Film by its UID. +func (c *FilmClient) Get(ctx context.Context, uid string) (*Film, error) { + var result Film + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Film into the database. +func (c *FilmClient) Add(ctx context.Context, v *Film) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Film in the database. The UID field must be set. +func (c *FilmClient) Update(ctx context.Context, v *Film) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Film with the given UID from the database. +func (c *FilmClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Film entities whose Name matches term using fulltext search. +func (c *FilmClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Film, error) { + var results []Film + q := c.conn.Query(ctx, Film{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Film entities with optional pagination. +func (c *FilmClient) List(ctx context.Context, opts ...PageOption) ([]Film, error) { + var results []Film + q := c.conn.Query(ctx, Film{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/film_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/film_options_gen.go new file mode 100644 index 0000000..3d84f8f --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/film_options_gen.go @@ -0,0 +1,38 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "time" +) + +// FilmOption is a functional option for configuring Film mutations. +type FilmOption func(*Film) + +// WithFilmName sets the Name field on a Film. +func WithFilmName(v string) FilmOption { + return func(e *Film) { + e.Name = v + } +} + +// WithFilmInitialReleaseDate sets the InitialReleaseDate field on a Film. +func WithFilmInitialReleaseDate(v time.Time) FilmOption { + return func(e *Film) { + e.InitialReleaseDate = v + } +} + +// WithFilmTagline sets the Tagline field on a Film. +func WithFilmTagline(v string) FilmOption { + return func(e *Film) { + e.Tagline = v + } +} + +// ApplyFilmOptions applies the given options to a Film. +func ApplyFilmOptions(e *Film, opts ...FilmOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/film_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/film_query_gen.go new file mode 100644 index 0000000..6492f22 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/film_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// FilmQuery is a typed query builder for Film entities. +type FilmQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Film entities. +func (c *FilmClient) Query(ctx context.Context) *FilmQuery { + return &FilmQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *FilmQuery) Filter(f string) *FilmQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *FilmQuery) OrderAsc(field string) *FilmQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *FilmQuery) OrderDesc(field string) *FilmQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *FilmQuery) First(n int) *FilmQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *FilmQuery) Offset(n int) *FilmQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *FilmQuery) Exec(dst *[]Film) error { + dq := q.conn.Query(q.ctx, Film{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *FilmQuery) ExecAndCount(dst *[]Film) (int, error) { + dq := q.conn.Query(q.ctx, Film{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/genre_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_gen.go new file mode 100644 index 0000000..46ec8eb --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// GenreClient provides typed CRUD operations for Genre entities. +type GenreClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Genre by its UID. +func (c *GenreClient) Get(ctx context.Context, uid string) (*Genre, error) { + var result Genre + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Genre into the database. +func (c *GenreClient) Add(ctx context.Context, v *Genre) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Genre in the database. The UID field must be set. +func (c *GenreClient) Update(ctx context.Context, v *Genre) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Genre with the given UID from the database. +func (c *GenreClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Genre entities whose Name matches term using fulltext search. +func (c *GenreClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Genre, error) { + var results []Genre + q := c.conn.Query(ctx, Genre{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Genre entities with optional pagination. +func (c *GenreClient) List(ctx context.Context, opts ...PageOption) ([]Genre, error) { + var results []Genre + q := c.conn.Query(ctx, Genre{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/genre_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_options_gen.go new file mode 100644 index 0000000..d923ce5 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// GenreOption is a functional option for configuring Genre mutations. +type GenreOption func(*Genre) + +// WithGenreName sets the Name field on a Genre. +func WithGenreName(v string) GenreOption { + return func(e *Genre) { + e.Name = v + } +} + +// ApplyGenreOptions applies the given options to a Genre. +func ApplyGenreOptions(e *Genre, opts ...GenreOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/genre_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_query_gen.go new file mode 100644 index 0000000..4d42408 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/genre_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// GenreQuery is a typed query builder for Genre entities. +type GenreQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Genre entities. +func (c *GenreClient) Query(ctx context.Context) *GenreQuery { + return &GenreQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *GenreQuery) Filter(f string) *GenreQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *GenreQuery) OrderAsc(field string) *GenreQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *GenreQuery) OrderDesc(field string) *GenreQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *GenreQuery) First(n int) *GenreQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *GenreQuery) Offset(n int) *GenreQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *GenreQuery) Exec(dst *[]Genre) error { + dq := q.conn.Query(q.ctx, Genre{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *GenreQuery) ExecAndCount(dst *[]Genre) (int, error) { + dq := q.conn.Query(q.ctx, Genre{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/iter_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/iter_gen.go new file mode 100644 index 0000000..373e84b --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/iter_gen.go @@ -0,0 +1,484 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + "iter" +) + +// SearchIter returns an iterator over Actor entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *ActorClient) SearchIter(ctx context.Context, term string) iter.Seq2[Actor, error] { + return func(yield func(Actor, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Actor + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Actor entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *ActorClient) ListIter(ctx context.Context) iter.Seq2[Actor, error] { + return func(yield func(Actor, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Actor + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over ContentRating entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *ContentRatingClient) SearchIter(ctx context.Context, term string) iter.Seq2[ContentRating, error] { + return func(yield func(ContentRating, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero ContentRating + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all ContentRating entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *ContentRatingClient) ListIter(ctx context.Context) iter.Seq2[ContentRating, error] { + return func(yield func(ContentRating, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero ContentRating + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Country entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *CountryClient) SearchIter(ctx context.Context, term string) iter.Seq2[Country, error] { + return func(yield func(Country, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Country + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Country entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *CountryClient) ListIter(ctx context.Context) iter.Seq2[Country, error] { + return func(yield func(Country, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Country + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Director entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *DirectorClient) SearchIter(ctx context.Context, term string) iter.Seq2[Director, error] { + return func(yield func(Director, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Director + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Director entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *DirectorClient) ListIter(ctx context.Context) iter.Seq2[Director, error] { + return func(yield func(Director, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Director + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Film entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *FilmClient) SearchIter(ctx context.Context, term string) iter.Seq2[Film, error] { + return func(yield func(Film, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Film + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Film entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *FilmClient) ListIter(ctx context.Context) iter.Seq2[Film, error] { + return func(yield func(Film, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Film + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Genre entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *GenreClient) SearchIter(ctx context.Context, term string) iter.Seq2[Genre, error] { + return func(yield func(Genre, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Genre + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Genre entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *GenreClient) ListIter(ctx context.Context) iter.Seq2[Genre, error] { + return func(yield func(Genre, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Genre + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Location entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *LocationClient) SearchIter(ctx context.Context, term string) iter.Seq2[Location, error] { + return func(yield func(Location, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Location + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Location entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *LocationClient) ListIter(ctx context.Context) iter.Seq2[Location, error] { + return func(yield func(Location, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Location + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Performance entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *PerformanceClient) ListIter(ctx context.Context) iter.Seq2[Performance, error] { + return func(yield func(Performance, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Performance + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// SearchIter returns an iterator over Rating entities matching term. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *RatingClient) SearchIter(ctx context.Context, term string) iter.Seq2[Rating, error] { + return func(yield func(Rating, error) bool) { + offset := 0 + for { + results, err := c.Search(ctx, term, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Rating + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} + +// ListIter returns an iterator over all Rating entities. +// It automatically pages through results using Go 1.23+ range-over-func. +func (c *RatingClient) ListIter(ctx context.Context) iter.Seq2[Rating, error] { + return func(yield func(Rating, error) bool) { + offset := 0 + for { + results, err := c.List(ctx, First(defaultPageSize), Offset(offset)) + if err != nil { + var zero Rating + yield(zero, err) + return + } + if len(results) == 0 { + return + } + for _, r := range results { + if !yield(r, nil) { + return + } + } + if len(results) < defaultPageSize { + return + } + offset += len(results) + } + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/location_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/location_gen.go new file mode 100644 index 0000000..6d1b0ab --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/location_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// LocationClient provides typed CRUD operations for Location entities. +type LocationClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Location by its UID. +func (c *LocationClient) Get(ctx context.Context, uid string) (*Location, error) { + var result Location + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Location into the database. +func (c *LocationClient) Add(ctx context.Context, v *Location) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Location in the database. The UID field must be set. +func (c *LocationClient) Update(ctx context.Context, v *Location) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Location with the given UID from the database. +func (c *LocationClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Location entities whose Name matches term using fulltext search. +func (c *LocationClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Location, error) { + var results []Location + q := c.conn.Query(ctx, Location{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Location entities with optional pagination. +func (c *LocationClient) List(ctx context.Context, opts ...PageOption) ([]Location, error) { + var results []Location + q := c.conn.Query(ctx, Location{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/location_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/location_options_gen.go new file mode 100644 index 0000000..f782994 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/location_options_gen.go @@ -0,0 +1,34 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// LocationOption is a functional option for configuring Location mutations. +type LocationOption func(*Location) + +// WithLocationName sets the Name field on a Location. +func WithLocationName(v string) LocationOption { + return func(e *Location) { + e.Name = v + } +} + +// WithLocationLoc sets the Loc field on a Location. +func WithLocationLoc(v []float64) LocationOption { + return func(e *Location) { + e.Loc = v + } +} + +// WithLocationEmail sets the Email field on a Location. +func WithLocationEmail(v string) LocationOption { + return func(e *Location) { + e.Email = v + } +} + +// ApplyLocationOptions applies the given options to a Location. +func ApplyLocationOptions(e *Location, opts ...LocationOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/location_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/location_query_gen.go new file mode 100644 index 0000000..6a2e9cd --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/location_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// LocationQuery is a typed query builder for Location entities. +type LocationQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Location entities. +func (c *LocationClient) Query(ctx context.Context) *LocationQuery { + return &LocationQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *LocationQuery) Filter(f string) *LocationQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *LocationQuery) OrderAsc(field string) *LocationQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *LocationQuery) OrderDesc(field string) *LocationQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *LocationQuery) First(n int) *LocationQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *LocationQuery) Offset(n int) *LocationQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *LocationQuery) Exec(dst *[]Location) error { + dq := q.conn.Query(q.ctx, Location{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *LocationQuery) ExecAndCount(dst *[]Location) (int, error) { + dq := q.conn.Query(q.ctx, Location{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/page_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/page_options_gen.go new file mode 100644 index 0000000..d641294 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/page_options_gen.go @@ -0,0 +1,37 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +const defaultPageSize = 50 + +// PageOption configures pagination for queries. +type PageOption interface { + applyPage(cfg *pageConfig) +} + +type pageConfig struct { + first int + offset int +} + +type firstOption int + +func (f firstOption) applyPage(cfg *pageConfig) { + cfg.first = int(f) +} + +// First limits the number of results returned. +func First(n int) PageOption { + return firstOption(n) +} + +type offsetOption int + +func (o offsetOption) applyPage(cfg *pageConfig) { + cfg.offset = int(o) +} + +// Offset skips the first n results. +func Offset(n int) PageOption { + return offsetOption(n) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/performance_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_gen.go new file mode 100644 index 0000000..fabd32c --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_gen.go @@ -0,0 +1,61 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// PerformanceClient provides typed CRUD operations for Performance entities. +type PerformanceClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Performance by its UID. +func (c *PerformanceClient) Get(ctx context.Context, uid string) (*Performance, error) { + var result Performance + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Performance into the database. +func (c *PerformanceClient) Add(ctx context.Context, v *Performance) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Performance in the database. The UID field must be set. +func (c *PerformanceClient) Update(ctx context.Context, v *Performance) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Performance with the given UID from the database. +func (c *PerformanceClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// List retrieves Performance entities with optional pagination. +func (c *PerformanceClient) List(ctx context.Context, opts ...PageOption) ([]Performance, error) { + var results []Performance + q := c.conn.Query(ctx, Performance{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/performance_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_options_gen.go new file mode 100644 index 0000000..50b9d7f --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// PerformanceOption is a functional option for configuring Performance mutations. +type PerformanceOption func(*Performance) + +// WithPerformanceCharacterNote sets the CharacterNote field on a Performance. +func WithPerformanceCharacterNote(v string) PerformanceOption { + return func(e *Performance) { + e.CharacterNote = v + } +} + +// ApplyPerformanceOptions applies the given options to a Performance. +func ApplyPerformanceOptions(e *Performance, opts ...PerformanceOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/performance_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_query_gen.go new file mode 100644 index 0000000..d57976c --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/performance_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// PerformanceQuery is a typed query builder for Performance entities. +type PerformanceQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Performance entities. +func (c *PerformanceClient) Query(ctx context.Context) *PerformanceQuery { + return &PerformanceQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *PerformanceQuery) Filter(f string) *PerformanceQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *PerformanceQuery) OrderAsc(field string) *PerformanceQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *PerformanceQuery) OrderDesc(field string) *PerformanceQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *PerformanceQuery) First(n int) *PerformanceQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *PerformanceQuery) Offset(n int) *PerformanceQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *PerformanceQuery) Exec(dst *[]Performance) error { + dq := q.conn.Query(q.ctx, Performance{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *PerformanceQuery) ExecAndCount(dst *[]Performance) (int, error) { + dq := q.conn.Query(q.ctx, Performance{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/rating_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_gen.go new file mode 100644 index 0000000..b1db2af --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_gen.go @@ -0,0 +1,84 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// RatingClient provides typed CRUD operations for Rating entities. +type RatingClient struct { + conn modusgraph.Client +} + +// Get retrieves a single Rating by its UID. +func (c *RatingClient) Get(ctx context.Context, uid string) (*Rating, error) { + var result Rating + err := c.conn.Get(ctx, &result, uid) + if err != nil { + return nil, err + } + return &result, nil +} + +// Add inserts a new Rating into the database. +func (c *RatingClient) Add(ctx context.Context, v *Rating) error { + return c.conn.Insert(ctx, v) +} + +// Update modifies an existing Rating in the database. The UID field must be set. +func (c *RatingClient) Update(ctx context.Context, v *Rating) error { + return c.conn.Update(ctx, v) +} + +// Delete removes the Rating with the given UID from the database. +func (c *RatingClient) Delete(ctx context.Context, uid string) error { + return c.conn.Delete(ctx, []string{uid}) +} + +// Search finds Rating entities whose Name matches term using fulltext search. +func (c *RatingClient) Search(ctx context.Context, term string, opts ...PageOption) ([]Rating, error) { + var results []Rating + q := c.conn.Query(ctx, Rating{}). + Filter(`alloftext(name, "` + term + `")`). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} + +// List retrieves Rating entities with optional pagination. +func (c *RatingClient) List(ctx context.Context, opts ...PageOption) ([]Rating, error) { + var results []Rating + q := c.conn.Query(ctx, Rating{}). + First(defaultPageSize) + cfg := pageConfig{first: defaultPageSize} + for _, opt := range opts { + opt.applyPage(&cfg) + } + if cfg.first > 0 { + q = q.First(cfg.first) + } + if cfg.offset > 0 { + q = q.Offset(cfg.offset) + } + err := q.Nodes(&results) + if err != nil { + return nil, err + } + return results, nil +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/rating_options_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_options_gen.go new file mode 100644 index 0000000..c3032ab --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_options_gen.go @@ -0,0 +1,20 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +// RatingOption is a functional option for configuring Rating mutations. +type RatingOption func(*Rating) + +// WithRatingName sets the Name field on a Rating. +func WithRatingName(v string) RatingOption { + return func(e *Rating) { + e.Name = v + } +} + +// ApplyRatingOptions applies the given options to a Rating. +func ApplyRatingOptions(e *Rating, opts ...RatingOption) { + for _, opt := range opts { + opt(e) + } +} diff --git a/cmd/modusgraphgen/internal/generator/testdata/golden/rating_query_gen.go b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_query_gen.go new file mode 100644 index 0000000..1cccaf6 --- /dev/null +++ b/cmd/modusgraphgen/internal/generator/testdata/golden/rating_query_gen.go @@ -0,0 +1,101 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) + +// RatingQuery is a typed query builder for Rating entities. +type RatingQuery struct { + conn modusgraph.Client + ctx context.Context + filter string + first int + offset int + orderBy string + orderDesc bool +} + +// Query begins a new query for Rating entities. +func (c *RatingClient) Query(ctx context.Context) *RatingQuery { + return &RatingQuery{conn: c.conn, ctx: ctx, first: defaultPageSize} +} + +// Filter adds a DQL filter expression to the query. +func (q *RatingQuery) Filter(f string) *RatingQuery { + q.filter = f + return q +} + +// OrderAsc sets ascending order on the given field. +func (q *RatingQuery) OrderAsc(field string) *RatingQuery { + q.orderBy = field + q.orderDesc = false + return q +} + +// OrderDesc sets descending order on the given field. +func (q *RatingQuery) OrderDesc(field string) *RatingQuery { + q.orderBy = field + q.orderDesc = true + return q +} + +// First limits the result to n nodes. +func (q *RatingQuery) First(n int) *RatingQuery { + q.first = n + return q +} + +// Offset skips the first n nodes. +func (q *RatingQuery) Offset(n int) *RatingQuery { + q.offset = n + return q +} + +// Exec executes the query and populates dst with the results. +func (q *RatingQuery) Exec(dst *[]Rating) error { + dq := q.conn.Query(q.ctx, Rating{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.Nodes(dst) +} + +// ExecAndCount executes the query and returns both the results and total count. +func (q *RatingQuery) ExecAndCount(dst *[]Rating) (int, error) { + dq := q.conn.Query(q.ctx, Rating{}) + if q.filter != "" { + dq = dq.Filter(q.filter) + } + if q.first > 0 { + dq = dq.First(q.first) + } + if q.offset > 0 { + dq = dq.Offset(q.offset) + } + if q.orderBy != "" { + if q.orderDesc { + dq = dq.OrderDesc(q.orderBy) + } else { + dq = dq.OrderAsc(q.orderBy) + } + } + return dq.NodesAndCount(dst) +} diff --git a/cmd/modusgraphgen/internal/model/model.go b/cmd/modusgraphgen/internal/model/model.go new file mode 100644 index 0000000..9edd01c --- /dev/null +++ b/cmd/modusgraphgen/internal/model/model.go @@ -0,0 +1,40 @@ +// Package model defines the intermediate representation used between the parser +// and the code generator. The parser populates these types from Go struct ASTs; +// the generator reads them to emit typed client code. +package model + +// Package represents the fully parsed target package and all its entities. +type Package struct { + Name string // Go package name, e.g. "movies" + ModulePath string // Full module path, e.g. "github.com/mlwelles/modusGraphMoviesProject" + Imports map[string]string // Package alias → import path, e.g. "enums" → "github.com/.../enums" + Entities []Entity // All detected entities (structs with UID + DType) + CLIName string // Name for CLI binary (kong.Name), defaults to Name if empty + WithValidator bool // Whether the generated CLI enables struct validation +} + +// Entity represents a single Dgraph type derived from a Go struct. +type Entity struct { + Name string // Go struct name, e.g. "Film" + Fields []Field // All exported fields from the struct + Searchable bool // True if the entity has a string field with index=fulltext + SearchField string // Name of the field with fulltext index (empty if not searchable) +} + +// Field represents a single exported field within an entity struct. +type Field struct { + Name string // Go field name, e.g. "InitialReleaseDate" + GoType string // Go type as string, e.g. "time.Time", "string", "[]Genre" + JSONTag string // Value from the json struct tag, e.g. "initialReleaseDate" + Predicate string // Resolved Dgraph predicate name + IsEdge bool // True if the field type is a slice of another entity + EdgeEntity string // Target entity name for edge fields, e.g. "Genre" + IsReverse bool // True if dgraph tag contains "reverse" or predicate starts with "~" + HasCount bool // True if dgraph tag contains "count" + Indexes []string // Parsed index directives, e.g. ["hash", "term", "trigram", "fulltext"] + TypeHint string // Value from dgraph "type=" directive, e.g. "geo", "datetime" + IsUID bool // True if the field represents the UID + IsDType bool // True if the field represents the DType (dgraph.type) + OmitEmpty bool // True if json tag contains ",omitempty" + Upsert bool // True if dgraph tag contains "upsert" +} diff --git a/cmd/modusgraphgen/internal/parser/inference.go b/cmd/modusgraphgen/internal/parser/inference.go new file mode 100644 index 0000000..9bef383 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/inference.go @@ -0,0 +1,54 @@ +package parser + +import ( + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" +) + +// applyInference applies higher-level inference rules to an entity after its +// fields have been parsed. This includes detecting searchability, determining +// which fields support year-range filters, and so on. +// +// Inference rules: +// +// - Searchable: An entity is searchable if it has a string field with +// "fulltext" in its index list. The SearchField is set to that field's name. +// +// - Relationships (edges): Already detected during struct parsing based on +// whether the field type is []OtherEntity. +// +// - Reverse edges: Already detected during tag parsing. A field is a reverse +// edge if its predicate starts with "~" or the dgraph tag contains "reverse". +// +// - Year-filterable: A field with index=year (present in Indexes) and GoType +// containing "time.Time" can be filtered by year range. This is recorded in +// the field's Indexes and TypeHint for the generator to use. +// +// - Hash-filterable: A field with index=hash supports exact-match lookups. +func applyInference(entity *model.Entity) { + for _, f := range entity.Fields { + if f.IsUID || f.IsDType { + continue + } + // Searchable: string field with fulltext index. + if isStringType(f.GoType) && hasIndex(f.Indexes, "fulltext") { + entity.Searchable = true + entity.SearchField = f.Name + break // Use the first one found. + } + } +} + +// isStringType returns true if the Go type represents a string. +func isStringType(goType string) bool { + return goType == "string" +} + +// hasIndex returns true if the given index name appears in the index list. +func hasIndex(indexes []string, name string) bool { + for _, idx := range indexes { + if idx == name { + return true + } + } + return false +} diff --git a/cmd/modusgraphgen/internal/parser/parser.go b/cmd/modusgraphgen/internal/parser/parser.go new file mode 100644 index 0000000..2f2ac1d --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/parser.go @@ -0,0 +1,354 @@ +// Package parser extracts entity and field metadata from Go source files by +// inspecting struct declarations and their struct tags. It uses go/ast and +// go/parser to walk the AST, then builds a model.Package for the generator. +package parser + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" +) + +// Parse loads all Go source files in the directory at pkgDir, extracts exported +// structs, and returns a model.Package with fully resolved entities and fields. +func Parse(pkgDir string) (*model.Package, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, pkgDir, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parsing package at %s: %w", pkgDir, err) + } + + if len(pkgs) == 0 { + return nil, fmt.Errorf("no Go packages found in %s", pkgDir) + } + + // Take the first (and typically only) non-test package. + var pkgName string + var pkgAST *ast.Package + for name, pkg := range pkgs { + if strings.HasSuffix(name, "_test") { + continue + } + pkgName = name + pkgAST = pkg + break + } + if pkgAST == nil { + return nil, fmt.Errorf("no non-test package found in %s", pkgDir) + } + + // First pass: collect all struct names so we can identify edges. + structNames := collectStructNames(pkgAST) + + // Second pass: parse each struct into an Entity. + var entities []model.Entity + for _, file := range pkgAST.Files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + if !typeSpec.Name.IsExported() { + continue + } + + entity, isEntity := parseStruct(typeSpec.Name.Name, structType, structNames) + if isEntity { + entities = append(entities, entity) + } + } + } + } + + // Collect import mappings: package alias → full import path. + imports := collectImports(pkgAST) + + // Read the module path from go.mod. + modulePath := readModulePath(pkgDir) + + return &model.Package{ + Name: pkgName, + ModulePath: modulePath, + Imports: imports, + Entities: entities, + }, nil +} + +// collectStructNames returns a set of all exported struct type names in the package. +func collectStructNames(pkg *ast.Package) map[string]bool { + names := make(map[string]bool) + for _, file := range pkg.Files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if _, ok := typeSpec.Type.(*ast.StructType); ok { + if typeSpec.Name.IsExported() { + names[typeSpec.Name.Name] = true + } + } + } + } + } + return names +} + +// parseStruct parses a single struct into a model.Entity. Returns the entity and +// true if the struct qualifies as an entity (has both UID and DType fields), +// or a zero Entity and false otherwise. +func parseStruct(name string, st *ast.StructType, structNames map[string]bool) (model.Entity, bool) { + var fields []model.Field + hasUID := false + hasDType := false + + for _, f := range st.Fields.List { + if len(f.Names) == 0 { + continue // embedded field, skip + } + fieldName := f.Names[0].Name + if !ast.IsExported(fieldName) { + continue + } + + goType := typeString(f.Type) + field := model.Field{ + Name: fieldName, + GoType: goType, + } + + // Parse struct tags. + if f.Tag != nil { + tagValue := strings.Trim(f.Tag.Value, "`") + tag := reflect.StructTag(tagValue) + + // Parse json tag. + jsonTag := tag.Get("json") + if jsonTag != "" { + parts := strings.SplitN(jsonTag, ",", 2) + field.JSONTag = parts[0] + if len(parts) > 1 && strings.Contains(parts[1], "omitempty") { + field.OmitEmpty = true + } + } + + // Parse dgraph tag. + dgraphTag := tag.Get("dgraph") + if dgraphTag != "" { + parseDgraphTag(dgraphTag, &field) + } + } + + // Detect UID and DType fields. + if fieldName == "UID" && goType == "string" { + field.IsUID = true + hasUID = true + } + if fieldName == "DType" && goType == "[]string" { + field.IsDType = true + hasDType = true + } + + // Resolve predicate: use explicit predicate if set, else fall back to json tag. + if field.Predicate == "" { + field.Predicate = field.JSONTag + } + + // Detect edges: field type is []SomeEntity where SomeEntity is a known struct. + if strings.HasPrefix(goType, "[]") { + elemType := goType[2:] + if structNames[elemType] { + field.IsEdge = true + field.EdgeEntity = elemType + } + } + + // Detect reverse edges from predicate. + if strings.HasPrefix(field.Predicate, "~") { + field.IsReverse = true + } + + fields = append(fields, field) + } + + if !hasUID || !hasDType { + return model.Entity{}, false + } + + entity := model.Entity{ + Name: name, + Fields: fields, + } + + // Apply inference rules. + applyInference(&entity) + + return entity, true +} + +// typeString converts an ast.Expr representing a type into a human-readable Go +// type string, e.g. "string", "time.Time", "[]Genre", "[]float64". +func typeString(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.SelectorExpr: + // e.g., time.Time + if x, ok := t.X.(*ast.Ident); ok { + return x.Name + "." + t.Sel.Name + } + return t.Sel.Name + case *ast.ArrayType: + if t.Len == nil { + // slice type + return "[]" + typeString(t.Elt) + } + // array type (unlikely in our structs but handle it) + return "[...]" + typeString(t.Elt) + case *ast.StarExpr: + return "*" + typeString(t.X) + case *ast.MapType: + return "map[" + typeString(t.Key) + "]" + typeString(t.Value) + default: + return fmt.Sprintf("%T", expr) + } +} + +// collectImports scans all files in the package and builds a map from package +// alias (the local name used in qualified types like enums.ResourceType) to the +// full import path (e.g., "github.com/Istari-digital/.../enums"). +func collectImports(pkg *ast.Package) map[string]string { + imports := make(map[string]string) + for _, file := range pkg.Files { + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + var alias string + if imp.Name != nil { + alias = imp.Name.Name + } else { + // Default alias is the last path segment. + parts := strings.Split(path, "/") + alias = parts[len(parts)-1] + } + imports[alias] = path + } + } + return imports +} + +// readModulePath reads the go.mod file in or above pkgDir and extracts the +// module path. It walks up from pkgDir looking for go.mod. Returns empty +// string if no go.mod is found. +func readModulePath(pkgDir string) string { + dir := pkgDir + for { + goModPath := filepath.Join(dir, "go.mod") + data, err := os.ReadFile(goModPath) + if err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")) + } + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// parseDgraphTag parses a dgraph struct tag value into its component parts and +// populates the corresponding fields on the model.Field. +// +// The dgraph tag uses a mixed format where space separates independent +// directives and commas separate values within a directive: +// +// dgraph:"predicate=initial_release_date index=year" +// dgraph:"predicate=genre,reverse,count" +// dgraph:"index=hash,term,trigram,fulltext" +// dgraph:"index=geo,type=geo" +// dgraph:"index=exact,upsert" +// dgraph:"count" +// +// Parsing rules: +// 1. Split on spaces first to get independent directives. +// 2. For each directive, split on commas to get tokens. +// 3. Each token is either "key=value" or a bare flag. +// 4. Special handling: "predicate=" sets the predicate, "index=" starts an index +// list, "type=" sets the type hint, "reverse"/"count"/"upsert" are boolean flags. +// 5. Bare tokens after "index=" that don't contain "=" are additional index values. +func parseDgraphTag(tag string, field *model.Field) { + // Split on spaces for independent directives. + directives := strings.Fields(tag) + + for _, directive := range directives { + tokens := strings.Split(directive, ",") + inIndex := false + + for _, tok := range tokens { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + + if strings.HasPrefix(tok, "predicate=") { + field.Predicate = tok[len("predicate="):] + inIndex = false + continue + } + if strings.HasPrefix(tok, "index=") { + indexVal := tok[len("index="):] + field.Indexes = append(field.Indexes, indexVal) + inIndex = true + continue + } + if strings.HasPrefix(tok, "type=") { + field.TypeHint = tok[len("type="):] + inIndex = false + continue + } + + switch tok { + case "reverse": + field.IsReverse = true + inIndex = false + case "count": + field.HasCount = true + inIndex = false + case "upsert": + field.Upsert = true + inIndex = false + default: + // Bare token: if we were in an index= list, treat as additional index value. + if inIndex { + field.Indexes = append(field.Indexes, tok) + } + } + } + } +} diff --git a/cmd/modusgraphgen/internal/parser/parser_test.go b/cmd/modusgraphgen/internal/parser/parser_test.go new file mode 100644 index 0000000..f3e3439 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/parser_test.go @@ -0,0 +1,469 @@ +package parser + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" +) + +func moviesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "testdata", "movies") +} + +func TestParseMoviesPackage(t *testing.T) { + dir := moviesDir(t) + pkg, err := Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + if pkg.Name != "movies" { + t.Errorf("package name = %q, want %q", pkg.Name, "movies") + } + + // Build a map for easy lookup. + entityMap := make(map[string]*model.Entity, len(pkg.Entities)) + for i := range pkg.Entities { + entityMap[pkg.Entities[i].Name] = &pkg.Entities[i] + } + + t.Run("AllEntitiesDetected", func(t *testing.T) { + expected := []string{ + "Film", "Director", "Actor", "Performance", + "Genre", "Country", "Rating", "ContentRating", "Location", + } + for _, name := range expected { + if _, ok := entityMap[name]; !ok { + t.Errorf("entity %q not found; detected entities: %v", name, entityNames(pkg.Entities)) + } + } + if len(pkg.Entities) != len(expected) { + t.Errorf("got %d entities, want %d; detected: %v", len(pkg.Entities), len(expected), entityNames(pkg.Entities)) + } + }) + + t.Run("FilmSearchable", func(t *testing.T) { + film := entityMap["Film"] + if film == nil { + t.Fatal("Film entity not found") + } + if !film.Searchable { + t.Error("Film should be searchable") + } + if film.SearchField != "Name" { + t.Errorf("Film.SearchField = %q, want %q", film.SearchField, "Name") + } + }) + + t.Run("FilmInitialReleaseDate", func(t *testing.T) { + film := entityMap["Film"] + if film == nil { + t.Fatal("Film entity not found") + } + f := findField(film.Fields, "InitialReleaseDate") + if f == nil { + t.Fatal("Film.InitialReleaseDate field not found") + } + if f.Predicate != "initial_release_date" { + t.Errorf("predicate = %q, want %q", f.Predicate, "initial_release_date") + } + if !hasIndex(f.Indexes, "year") { + t.Errorf("indexes = %v, want to contain %q", f.Indexes, "year") + } + if f.GoType != "time.Time" { + t.Errorf("GoType = %q, want %q", f.GoType, "time.Time") + } + }) + + t.Run("FilmGenresEdge", func(t *testing.T) { + film := entityMap["Film"] + if film == nil { + t.Fatal("Film entity not found") + } + f := findField(film.Fields, "Genres") + if f == nil { + t.Fatal("Film.Genres field not found") + } + if !f.IsEdge { + t.Error("Genres should be an edge") + } + if f.EdgeEntity != "Genre" { + t.Errorf("EdgeEntity = %q, want %q", f.EdgeEntity, "Genre") + } + if f.Predicate != "genre" { + t.Errorf("predicate = %q, want %q", f.Predicate, "genre") + } + if !f.IsReverse { + t.Error("Genres should have reverse flag set") + } + if !f.HasCount { + t.Error("Genres should have count flag set") + } + }) + + t.Run("DirectorFilmsPredicate", func(t *testing.T) { + dir := entityMap["Director"] + if dir == nil { + t.Fatal("Director entity not found") + } + f := findField(dir.Fields, "Films") + if f == nil { + t.Fatal("Director.Films field not found") + } + if f.Predicate != "director.film" { + t.Errorf("predicate = %q, want %q", f.Predicate, "director.film") + } + if !f.IsEdge { + t.Error("Director.Films should be an edge") + } + if f.EdgeEntity != "Film" { + t.Errorf("EdgeEntity = %q, want %q", f.EdgeEntity, "Film") + } + if !f.IsReverse { + t.Error("Director.Films should have reverse flag set") + } + if !f.HasCount { + t.Error("Director.Films should have count flag set") + } + }) + + t.Run("GenreFilmsReverse", func(t *testing.T) { + genre := entityMap["Genre"] + if genre == nil { + t.Fatal("Genre entity not found") + } + f := findField(genre.Fields, "Films") + if f == nil { + t.Fatal("Genre.Films field not found") + } + if f.Predicate != "~genre" { + t.Errorf("predicate = %q, want %q", f.Predicate, "~genre") + } + if !f.IsReverse { + t.Error("Genre.Films should be a reverse edge") + } + if !f.IsEdge { + t.Error("Genre.Films should be an edge") + } + }) + + t.Run("ActorFilmsPredicate", func(t *testing.T) { + actor := entityMap["Actor"] + if actor == nil { + t.Fatal("Actor entity not found") + } + f := findField(actor.Fields, "Films") + if f == nil { + t.Fatal("Actor.Films field not found") + } + if f.Predicate != "actor.film" { + t.Errorf("predicate = %q, want %q", f.Predicate, "actor.film") + } + if !f.IsEdge { + t.Error("Actor.Films should be an edge") + } + if f.EdgeEntity != "Performance" { + t.Errorf("EdgeEntity = %q, want %q", f.EdgeEntity, "Performance") + } + if !f.HasCount { + t.Error("Actor.Films should have count flag set") + } + }) + + t.Run("PerformanceCharacterNote", func(t *testing.T) { + perf := entityMap["Performance"] + if perf == nil { + t.Fatal("Performance entity not found") + } + f := findField(perf.Fields, "CharacterNote") + if f == nil { + t.Fatal("Performance.CharacterNote field not found") + } + if f.Predicate != "performance.character_note" { + t.Errorf("predicate = %q, want %q", f.Predicate, "performance.character_note") + } + }) + + t.Run("LocationGeoIndex", func(t *testing.T) { + loc := entityMap["Location"] + if loc == nil { + t.Fatal("Location entity not found") + } + f := findField(loc.Fields, "Loc") + if f == nil { + t.Fatal("Location.Loc field not found") + } + if !hasIndex(f.Indexes, "geo") { + t.Errorf("indexes = %v, want to contain %q", f.Indexes, "geo") + } + if f.TypeHint != "geo" { + t.Errorf("TypeHint = %q, want %q", f.TypeHint, "geo") + } + }) + + t.Run("LocationEmailUpsert", func(t *testing.T) { + loc := entityMap["Location"] + if loc == nil { + t.Fatal("Location entity not found") + } + f := findField(loc.Fields, "Email") + if f == nil { + t.Fatal("Location.Email field not found") + } + if !f.Upsert { + t.Error("Email should have upsert flag set") + } + if !hasIndex(f.Indexes, "exact") { + t.Errorf("indexes = %v, want to contain %q", f.Indexes, "exact") + } + }) + + t.Run("ContentRatingReverse", func(t *testing.T) { + cr := entityMap["ContentRating"] + if cr == nil { + t.Fatal("ContentRating entity not found") + } + f := findField(cr.Fields, "Films") + if f == nil { + t.Fatal("ContentRating.Films field not found") + } + if f.Predicate != "~rated" { + t.Errorf("predicate = %q, want %q", f.Predicate, "~rated") + } + if !f.IsReverse { + t.Error("ContentRating.Films should be a reverse edge") + } + }) + + t.Run("AllEntitiesSearchable", func(t *testing.T) { + // These entities should be searchable (have Name with fulltext index): + // Film, Director, Actor, Genre, Country, Rating, ContentRating, Location + searchable := []string{"Film", "Director", "Actor", "Genre", "Country", "Rating", "ContentRating", "Location"} + for _, name := range searchable { + e := entityMap[name] + if e == nil { + t.Errorf("entity %q not found", name) + continue + } + if !e.Searchable { + t.Errorf("entity %q should be searchable", name) + } + if e.SearchField != "Name" { + t.Errorf("entity %q SearchField = %q, want %q", name, e.SearchField, "Name") + } + } + // Performance should NOT be searchable (no Name field with fulltext). + perf := entityMap["Performance"] + if perf != nil && perf.Searchable { + t.Error("Performance should NOT be searchable") + } + }) +} + +func TestParseDgraphTag(t *testing.T) { + tests := []struct { + name string + tag string + expected model.Field + }{ + { + name: "index only", + tag: "index=hash,term,trigram,fulltext", + expected: model.Field{ + Indexes: []string{"hash", "term", "trigram", "fulltext"}, + }, + }, + { + name: "predicate with space-separated index", + tag: "predicate=initial_release_date index=year", + expected: model.Field{ + Predicate: "initial_release_date", + Indexes: []string{"year"}, + }, + }, + { + name: "predicate with reverse and count", + tag: "predicate=genre,reverse,count", + expected: model.Field{ + Predicate: "genre", + IsReverse: true, + HasCount: true, + }, + }, + { + name: "count only", + tag: "count", + expected: model.Field{ + HasCount: true, + }, + }, + { + name: "index with type hint", + tag: "index=geo,type=geo", + expected: model.Field{ + Indexes: []string{"geo"}, + TypeHint: "geo", + }, + }, + { + name: "index with upsert", + tag: "index=exact,upsert", + expected: model.Field{ + Indexes: []string{"exact"}, + Upsert: true, + }, + }, + { + name: "tilde predicate", + tag: "predicate=~genre", + expected: model.Field{ + Predicate: "~genre", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f model.Field + parseDgraphTag(tt.tag, &f) + + if f.Predicate != tt.expected.Predicate { + t.Errorf("Predicate = %q, want %q", f.Predicate, tt.expected.Predicate) + } + if f.IsReverse != tt.expected.IsReverse { + t.Errorf("IsReverse = %v, want %v", f.IsReverse, tt.expected.IsReverse) + } + if f.HasCount != tt.expected.HasCount { + t.Errorf("HasCount = %v, want %v", f.HasCount, tt.expected.HasCount) + } + if f.Upsert != tt.expected.Upsert { + t.Errorf("Upsert = %v, want %v", f.Upsert, tt.expected.Upsert) + } + if f.TypeHint != tt.expected.TypeHint { + t.Errorf("TypeHint = %q, want %q", f.TypeHint, tt.expected.TypeHint) + } + if len(f.Indexes) != len(tt.expected.Indexes) { + t.Errorf("Indexes = %v, want %v", f.Indexes, tt.expected.Indexes) + } else { + for i := range f.Indexes { + if f.Indexes[i] != tt.expected.Indexes[i] { + t.Errorf("Indexes[%d] = %q, want %q", i, f.Indexes[i], tt.expected.Indexes[i]) + } + } + } + }) + } +} + +func TestReadModulePath(t *testing.T) { + t.Run("FromMoviesProject", func(t *testing.T) { + dir := moviesDir(t) + got := readModulePath(dir) + want := "github.com/mlwelles/modusGraphMoviesProject" + if got != want { + t.Errorf("readModulePath(%s) = %q, want %q", dir, got, want) + } + }) + + t.Run("FromModusGraph", func(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + dir := filepath.Dir(thisFile) + got := readModulePath(dir) + want := "github.com/matthewmcneely/modusgraph" + if got != want { + t.Errorf("readModulePath(%s) = %q, want %q", dir, got, want) + } + }) + + t.Run("EmptyForNonExistentDir", func(t *testing.T) { + got := readModulePath("/tmp/nonexistent-dir-for-test") + if got != "" { + t.Errorf("readModulePath(nonexistent) = %q, want empty string", got) + } + }) + + t.Run("FromTempGoMod", func(t *testing.T) { + dir := t.TempDir() + gomod := filepath.Join(dir, "go.mod") + if err := os.WriteFile(gomod, []byte("module example.com/test-project\n\ngo 1.21\n"), 0o644); err != nil { + t.Fatal(err) + } + got := readModulePath(dir) + want := "example.com/test-project" + if got != want { + t.Errorf("readModulePath(%s) = %q, want %q", dir, got, want) + } + }) + + t.Run("WalksUpToParent", func(t *testing.T) { + dir := t.TempDir() + gomod := filepath.Join(dir, "go.mod") + if err := os.WriteFile(gomod, []byte("module example.com/parent-project\n"), 0o644); err != nil { + t.Fatal(err) + } + subdir := filepath.Join(dir, "sub", "package") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + got := readModulePath(subdir) + want := "example.com/parent-project" + if got != want { + t.Errorf("readModulePath(%s) = %q, want %q", subdir, got, want) + } + }) +} + +func TestCollectImports(t *testing.T) { + dir := moviesDir(t) + pkg, err := Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + // The movies project imports "time" in film.go, which should appear + // in the imports map. + if path, ok := pkg.Imports["time"]; !ok { + t.Error("expected 'time' in imports map") + } else if path != "time" { + t.Errorf("imports[time] = %q, want %q", path, "time") + } +} + +func TestModulePathPopulated(t *testing.T) { + dir := moviesDir(t) + pkg, err := Parse(dir) + if err != nil { + t.Fatalf("Parse(%s) failed: %v", dir, err) + } + + if pkg.ModulePath != "github.com/mlwelles/modusGraphMoviesProject" { + t.Errorf("ModulePath = %q, want %q", pkg.ModulePath, "github.com/mlwelles/modusGraphMoviesProject") + } +} + +// findField returns the field with the given name, or nil if not found. +func findField(fields []model.Field, name string) *model.Field { + for i := range fields { + if fields[i].Name == name { + return &fields[i] + } + } + return nil +} + +// entityNames returns the names of all entities for diagnostic output. +func entityNames(entities []model.Entity) []string { + names := make([]string, len(entities)) + for i, e := range entities { + names[i] = e.Name + } + return names +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/actor.go b/cmd/modusgraphgen/internal/parser/testdata/movies/actor.go new file mode 100644 index 0000000..962af83 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/actor.go @@ -0,0 +1,8 @@ +package movies + +type Actor struct { + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` + Name string `json:"name,omitempty" dgraph:"index=hash,term,trigram,fulltext"` + Films []Performance `json:"films,omitempty" dgraph:"predicate=actor.film count"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/content_rating.go b/cmd/modusgraphgen/internal/parser/testdata/movies/content_rating.go new file mode 100644 index 0000000..c74b80f --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/content_rating.go @@ -0,0 +1,8 @@ +package movies + +type ContentRating 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=~rated reverse"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/country.go b/cmd/modusgraphgen/internal/parser/testdata/movies/country.go new file mode 100644 index 0000000..827f468 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/country.go @@ -0,0 +1,8 @@ +package movies + +type Country 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=~country reverse"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/director.go b/cmd/modusgraphgen/internal/parser/testdata/movies/director.go new file mode 100644 index 0000000..e1c4a17 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/director.go @@ -0,0 +1,8 @@ +package movies + +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"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/film.go b/cmd/modusgraphgen/internal/parser/testdata/movies/film.go new file mode 100644 index 0000000..4e8f8a7 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/film.go @@ -0,0 +1,16 @@ +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"` + Countries []Country `json:"countries,omitempty" dgraph:"predicate=country reverse"` + Ratings []Rating `json:"ratings,omitempty" dgraph:"predicate=rating reverse"` + ContentRatings []ContentRating `json:"contentRatings,omitempty" dgraph:"predicate=rated reverse"` + Starring []Performance `json:"starring,omitempty" dgraph:"count"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/generate.go b/cmd/modusgraphgen/internal/parser/testdata/movies/generate.go new file mode 100644 index 0000000..ca7d348 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/generate.go @@ -0,0 +1,3 @@ +package movies + +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/genre.go b/cmd/modusgraphgen/internal/parser/testdata/movies/genre.go new file mode 100644 index 0000000..520c1e0 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/genre.go @@ -0,0 +1,8 @@ +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"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/go.mod b/cmd/modusgraphgen/internal/parser/testdata/movies/go.mod new file mode 100644 index 0000000..ab35428 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/go.mod @@ -0,0 +1,3 @@ +module github.com/mlwelles/modusGraphMoviesProject + +go 1.25.6 diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/location.go b/cmd/modusgraphgen/internal/parser/testdata/movies/location.go new file mode 100644 index 0000000..a4f4348 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/location.go @@ -0,0 +1,9 @@ +package movies + +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"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/performance.go b/cmd/modusgraphgen/internal/parser/testdata/movies/performance.go new file mode 100644 index 0000000..3187564 --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/performance.go @@ -0,0 +1,7 @@ +package movies + +type Performance struct { + UID string `json:"uid,omitempty"` + DType []string `json:"dgraph.type,omitempty"` + CharacterNote string `json:"characterNote,omitempty" dgraph:"predicate=performance.character_note"` +} diff --git a/cmd/modusgraphgen/internal/parser/testdata/movies/rating.go b/cmd/modusgraphgen/internal/parser/testdata/movies/rating.go new file mode 100644 index 0000000..359d23b --- /dev/null +++ b/cmd/modusgraphgen/internal/parser/testdata/movies/rating.go @@ -0,0 +1,8 @@ +package movies + +type Rating 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=~rating reverse"` +} diff --git a/cmd/modusgraphgen/main.go b/cmd/modusgraphgen/main.go new file mode 100644 index 0000000..a500059 --- /dev/null +++ b/cmd/modusgraphgen/main.go @@ -0,0 +1,81 @@ +// 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/matthewmcneely/modusgraph/cmd/modusgraphgen [flags] +// +// When invoked via go:generate (the typical case), it uses the current working +// directory as the target package. +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/generator" + "github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser" +) + +func main() { + pkgDir := flag.String("pkg", ".", "path to the target Go package directory") + outputDir := flag.String("output", "", "output directory (default: same as -pkg)") + cliDir := flag.String("cli-dir", "", "output directory for CLI main.go (default: {output}/cmd/{package})") + cliName := flag.String("cli-name", "", "name for CLI binary and kong.Name (default: package name)") + withValidator := flag.Bool("with-validator", false, "enable struct validation via modusgraph.WithValidator in the generated CLI") + flag.Parse() + + // Resolve the package directory. + dir := *pkgDir + if dir == "." { + var err error + dir, err = os.Getwd() + if err != nil { + log.Fatalf("failed to get working directory: %v", err) + } + } + + // Resolve the output directory. + outDir := *outputDir + if outDir == "" { + outDir = dir + } + + // Parse phase: extract the model from Go source files. + pkg, err := parser.Parse(dir) + if err != nil { + log.Fatalf("parse error: %v", err) + } + + // Apply CLI name override if provided. + if *cliName != "" { + pkg.CLIName = *cliName + } + + // Apply validator flag. + pkg.WithValidator = *withValidator + + fmt.Printf("Package: %s\n", pkg.Name) + fmt.Printf("Entities: %d\n", len(pkg.Entities)) + for _, e := range pkg.Entities { + searchInfo := "" + if e.Searchable { + searchInfo = fmt.Sprintf(" (searchable on %s)", e.SearchField) + } + fmt.Printf(" - %s: %d fields%s\n", e.Name, len(e.Fields), searchInfo) + } + + // Generate phase: execute templates and write output files. + fmt.Printf("\nGenerating code into %s ...\n", outDir) + var genOpts []generator.GenerateOption + if *cliDir != "" { + genOpts = append(genOpts, generator.WithCLIDir(*cliDir)) + } + if err := generator.Generate(pkg, outDir, genOpts...); err != nil { + log.Fatalf("generation error: %v", err) + } + fmt.Println("Done.") +} diff --git a/docs/plans/2026-02-27-merge-modusgraphgen-design.md b/docs/plans/2026-02-27-merge-modusgraphgen-design.md new file mode 100644 index 0000000..464c389 --- /dev/null +++ b/docs/plans/2026-02-27-merge-modusgraphgen-design.md @@ -0,0 +1,99 @@ +# Design: Merge modusGraphGen into modusGraph + +**Date:** 2026-02-27 +**Status:** Approved + +## Summary + +Merge the standalone modusGraphGen code generator into the modusGraph repository as `cmd/modusgraphgen/` with internal packages. This consolidates the toolchain into a single repo, simplifying installation and maintenance. + +## Background + +modusGraphGen is a Go code generator that parses structs with `json`/`dgraph` tags and produces typed CRUD clients, query builders, iterators, functional options, and a CLI. It uses only the Go standard library (go/ast, go/parser, text/template, embed) and has zero external dependencies. The generated code imports `github.com/matthewmcneely/modusgraph`. + +Currently modusGraphGen lives in a separate repository (`github.com/mlwelles/modusGraphGen`). Merging it into modusGraph means consumers install one module and get both the library and the generator. + +## Directory Layout + +``` +cmd/modusgraphgen/ + main.go # CLI entry point + internal/ + model/ + model.go # IR types (Package, Entity, Field) + parser/ + parser.go # AST-based Go source parser + inference.go # Post-parse inference rules + parser_test.go # Tests using local testdata + testdata/ + movies/ # Fixtures copied from modusGraphMoviesProject + actor.go, film.go, director.go, genre.go, country.go, + rating.go, content_rating.go, performance.go, location.go, + generate.go + generator/ + generator.go # Template execution engine + generator_test.go # Golden file tests + templates/ # Embedded via go:embed + client.go.tmpl, entity.go.tmpl, query.go.tmpl, cli.go.tmpl, + iter.go.tmpl, options.go.tmpl, page_options.go.tmpl + testdata/golden/ # Golden test files + *.go +``` + +## Import Path Rewriting + +Internal imports change from: +- `github.com/mlwelles/modusGraphGen/{model,parser,generator}` + +To: +- `github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/{model,parser,generator}` + +Template output imports (`github.com/matthewmcneely/modusgraph`) remain unchanged. + +Consumer go:generate directives change to: +```go +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen +``` + +## Git Workflow + +1. Fetch upstream +2. Create `fork-main-pre-sync` branch from current `origin/main` to preserve divergence +3. Push preservation branch to origin +4. Reset local main to `upstream/main` +5. Force push main to origin (sync fork with upstream) +6. Create `feature/add-modusgraphgen` from clean main +7. Perform the merge (single squashed commit) +8. Push feature branch +9. Open PR to upstream (`matthewmcneely/modusgraph:main`) +10. Open PR to fork (`mlwelles/modusGraph:main`) + +## Test Strategy + +- Parser tests: rewrite to reference `testdata/movies/` instead of external sibling project +- Generator tests: golden file tests preserved in `testdata/golden/` +- Test fixtures include the 9 movies struct files plus `generate.go` +- No new dependencies required (gen tool uses only stdlib) + +## README Update + +Add a "Code Generation" section to the main README covering: +- Overview of what the gen tool does +- Installation via `go install` +- Usage via `go:generate` +- CLI flags reference +- Generated output file table +- Struct tag reference + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Placement | `cmd/modusgraphgen/` | Follows Go convention for CLI tools in a library repo | +| Package visibility | `internal/` | Gen internals are not public API | +| Import paths | Rewrite to new module paths | Clean integration, no multi-module complexity | +| Template import paths | Keep as-is | Templates correctly reference the upstream module path | +| Test data | Copy fixtures into testdata/ | Self-contained tests, no external dependencies | +| Git history | Single squashed commit | Clean PR, original repo preserves full history | +| Stub templates | Skip | Dead code, only real templates from generator/templates/ | +| Documentation | Section in main README | Single source of truth | diff --git a/docs/plans/2026-02-27-merge-modusgraphgen-plan.md b/docs/plans/2026-02-27-merge-modusgraphgen-plan.md new file mode 100644 index 0000000..0244fa7 --- /dev/null +++ b/docs/plans/2026-02-27-merge-modusgraphgen-plan.md @@ -0,0 +1,469 @@ +# Merge modusGraphGen into modusGraph Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Merge the standalone modusGraphGen code generator into the modusGraph repository as `cmd/modusgraphgen/` with internal packages, then open PRs to both upstream and fork. + +**Architecture:** Copy modusGraphGen source files into `cmd/modusgraphgen/internal/{model,parser,generator}`, rewrite internal import paths to the modusgraph module, copy test fixtures from modusGraphMoviesProject, update the README, and deliver via two PRs. + +**Tech Stack:** Go (stdlib only for gen tool: go/ast, go/parser, text/template, embed), git, gh CLI + +--- + +### Task 1: Git Setup -- Preserve Fork Divergence and Sync Main + +**Files:** +- No file changes -- git operations only. + +**Step 1: Fetch latest from both remotes** + +Run: `git fetch upstream && git fetch origin` + +**Step 2: Switch to main branch** + +Run: `git checkout main` + +**Step 3: Create preservation branch from current origin/main** + +This saves the 3 fork-only commits (slice tests, predicate tests, go.mod change). + +Run: `git checkout -b fork-main-pre-sync origin/main` +Run: `git push -u origin fork-main-pre-sync` + +**Step 4: Reset local main to upstream/main** + +Run: `git checkout main` +Run: `git reset --hard upstream/main` +Run: `git push --force origin main` + +Expected: Fork's main now matches upstream's main exactly. + +**Step 5: Create feature branch from clean main** + +Run: `git checkout -b feature/add-modusgraphgen` + +Expected: New branch at the same commit as upstream/main. + +--- + +### Task 2: Create Directory Structure + +**Files:** +- Create: `cmd/modusgraphgen/main.go` (placeholder) +- Create: `cmd/modusgraphgen/internal/model/` (directory) +- Create: `cmd/modusgraphgen/internal/parser/` (directory) +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/` (directory) +- Create: `cmd/modusgraphgen/internal/generator/` (directory) +- Create: `cmd/modusgraphgen/internal/generator/templates/` (directory) +- Create: `cmd/modusgraphgen/internal/generator/testdata/golden/` (directory) + +**Step 1: Create all directories** + +Run: +```bash +mkdir -p cmd/modusgraphgen/internal/model +mkdir -p cmd/modusgraphgen/internal/parser/testdata/movies +mkdir -p cmd/modusgraphgen/internal/generator/templates +mkdir -p cmd/modusgraphgen/internal/generator/testdata/golden +``` + +--- + +### Task 3: Copy and Adapt model Package + +**Files:** +- Create: `cmd/modusgraphgen/internal/model/model.go` + +**Step 1: Copy model.go** + +Copy from `../modusGraphGen/model/model.go`. The model package has no imports to rewrite -- it's pure Go types with no external dependencies. The file is identical to the source. + +Run: `cp ../modusGraphGen/model/model.go cmd/modusgraphgen/internal/model/model.go` + +**Step 2: Verify no import changes needed** + +The model package only declares types (`Package`, `Entity`, `Field`). It has no imports at all. No changes needed. + +--- + +### Task 4: Copy and Adapt parser Package + +**Files:** +- Create: `cmd/modusgraphgen/internal/parser/parser.go` +- Create: `cmd/modusgraphgen/internal/parser/inference.go` +- Create: `cmd/modusgraphgen/internal/parser/parser_test.go` + +**Step 1: Copy parser.go and rewrite imports** + +Copy from `../modusGraphGen/parser/parser.go`. Change the import: +- FROM: `"github.com/mlwelles/modusGraphGen/model"` +- TO: `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model"` + +Run: `cp ../modusGraphGen/parser/parser.go cmd/modusgraphgen/internal/parser/parser.go` + +Then edit the import path in the copied file. + +**Step 2: Copy inference.go and rewrite imports** + +Copy from `../modusGraphGen/parser/inference.go`. Same import change. + +Run: `cp ../modusGraphGen/parser/inference.go cmd/modusgraphgen/internal/parser/inference.go` + +Then edit the import path. + +**Step 3: Copy parser_test.go and rewrite imports + test paths** + +Copy from `../modusGraphGen/parser/parser_test.go`. Changes needed: +- Import: `"github.com/mlwelles/modusGraphGen/model"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model"` +- Replace `moviesDir()` function to point to local testdata instead of sibling project: + +The `moviesDir` function currently uses `runtime.Caller(0)` to navigate to `../../modusGraphMoviesProject/movies/`. Replace it with: + +```go +func moviesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "testdata", "movies") +} +``` + +- Update `TestReadModulePath/FromModusGraphGen` to expect `"github.com/matthewmcneely/modusgraph"` (the host module) instead of `"github.com/mlwelles/modusGraphGen"`. Note: `readModulePath` walks up from the test file directory to find go.mod, so it will now find the modusGraph go.mod. + +- Update `TestReadModulePath/FromMoviesProject` -- the movies testdata directory won't have its own go.mod, so we need to add a minimal go.mod to the testdata directory. Create `cmd/modusgraphgen/internal/parser/testdata/movies/go.mod` with: +``` +module github.com/mlwelles/modusGraphMoviesProject + +go 1.25.6 +``` + +- Update `TestModulePathPopulated` to expect `"github.com/mlwelles/modusGraphMoviesProject"` (from the testdata go.mod). + +- Update `TestCollectImports` -- no change needed (still tests for "time" import). + +--- + +### Task 5: Copy Test Fixtures (Movies Struct Files) + +**Files:** +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/actor.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/film.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/director.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/genre.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/country.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/rating.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/content_rating.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/performance.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/location.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/generate.go` +- Create: `cmd/modusgraphgen/internal/parser/testdata/movies/go.mod` + +**Step 1: Copy all struct files from modusGraphMoviesProject** + +Run: +```bash +for f in actor.go film.go director.go genre.go country.go rating.go content_rating.go performance.go location.go generate.go; do + cp ../modusGraphMoviesProject/movies/$f cmd/modusgraphgen/internal/parser/testdata/movies/$f +done +``` + +**Step 2: Update generate.go directive** + +Change the `go:generate` line in `cmd/modusgraphgen/internal/parser/testdata/movies/generate.go` from: +```go +//go:generate go run github.com/mlwelles/modusGraphGen +``` +to: +```go +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen +``` + +**Step 3: Create minimal go.mod for testdata** + +Create `cmd/modusgraphgen/internal/parser/testdata/movies/go.mod`: +``` +module github.com/mlwelles/modusGraphMoviesProject + +go 1.25.6 +``` + +This allows `readModulePath()` to resolve the module path in tests. + +--- + +### Task 6: Copy and Adapt generator Package + +**Files:** +- Create: `cmd/modusgraphgen/internal/generator/generator.go` +- Create: `cmd/modusgraphgen/internal/generator/generator_test.go` +- Create: `cmd/modusgraphgen/internal/generator/templates/*.tmpl` (7 files) +- Create: `cmd/modusgraphgen/internal/generator/testdata/golden/*.go` (30 files) + +**Step 1: Copy generator.go and rewrite imports** + +Copy from `../modusGraphGen/generator/generator.go`. Change: +- `"github.com/mlwelles/modusGraphGen/model"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model"` + +Run: `cp ../modusGraphGen/generator/generator.go cmd/modusgraphgen/internal/generator/generator.go` + +Then edit the import. + +**Step 2: Copy generator_test.go and rewrite imports + paths** + +Copy from `../modusGraphGen/generator/generator_test.go`. Changes: +- `"github.com/mlwelles/modusGraphGen/model"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model"` +- `"github.com/mlwelles/modusGraphGen/parser"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser"` + +Replace `moviesDir()` to point to the parser's testdata (shared fixtures): + +```go +func moviesDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + // thisFile = .../generator/generator_test.go + // testdata is at .../parser/testdata/movies/ + genDir := filepath.Dir(thisFile) + return filepath.Join(filepath.Dir(genDir), "parser", "testdata", "movies") +} +``` + +Run: `cp ../modusGraphGen/generator/generator_test.go cmd/modusgraphgen/internal/generator/generator_test.go` + +Then edit imports and moviesDir. + +**Step 3: Copy all template files** + +Run: +```bash +cp ../modusGraphGen/generator/templates/*.tmpl cmd/modusgraphgen/internal/generator/templates/ +``` + +Templates reference `github.com/matthewmcneely/modusgraph` in generated output -- no changes needed. + +**Step 4: Copy all golden files** + +Run: +```bash +cp ../modusGraphGen/generator/testdata/golden/*.go cmd/modusgraphgen/internal/generator/testdata/golden/ +``` + +Golden files are generated output snapshots -- no changes needed. + +--- + +### Task 7: Copy and Adapt main.go Entry Point + +**Files:** +- Create: `cmd/modusgraphgen/main.go` + +**Step 1: Copy main.go and rewrite imports** + +Copy from `../modusGraphGen/main.go`. Changes: +- `"github.com/mlwelles/modusGraphGen/generator"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/generator"` +- `"github.com/mlwelles/modusGraphGen/parser"` → `"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser"` +- Update the doc comment: `go run github.com/mlwelles/modusGraphGen` → `go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen` + +Run: `cp ../modusGraphGen/main.go cmd/modusgraphgen/main.go` + +Then edit imports and doc comment. + +--- + +### Task 8: Run Tests + +**Step 1: Run parser tests** + +Run: `go test -v ./cmd/modusgraphgen/internal/parser/...` + +Expected: All tests pass including entity detection, field parsing, tag parsing, module path resolution. + +**Step 2: Run generator tests** + +Run: `go test -v ./cmd/modusgraphgen/internal/generator/...` + +Expected: All golden file comparisons pass, output file checks pass, snake_case conversion tests pass. + +**Step 3: Run the full modusGraph test suite to check for regressions** + +Run: `go test -short -race -v .` + +Expected: All existing tests still pass. + +**Step 4: Verify the gen tool builds** + +Run: `go build ./cmd/modusgraphgen/` + +Expected: Clean build, no errors. + +--- + +### Task 9: Update README + +**Files:** +- Modify: `README.md` (add Code Generation section before Limitations) + +**Step 1: Add Code Generation section** + +Insert a new section before the "Limitations" section (around line 630). The section should cover: + +```markdown +## 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 | `_gen.go` | Per entity -- `Get`, `Add`, `Update`, `Delete`, `Search`, `List` | +| options | `_options_gen.go` | Per entity -- functional options for each scalar field | +| query | `_query_gen.go` | Per entity -- fluent query builder | +| cli | `cmd//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. +``` + +--- + +### Task 10: Commit and Push + +**Step 1: Stage all new and modified files** + +Run: `git add cmd/modusgraphgen/ README.md docs/plans/` + +**Step 2: Commit** + +Run: +```bash +git commit -m "feat: add modusgraphgen code generator + +Merge the standalone modusGraphGen code generator into the modusGraph +repository as cmd/modusgraphgen/. The tool parses Go structs with +json/dgraph tags and generates typed CRUD clients, query builders, +auto-paging iterators, functional options, and a Kong CLI. + +Code is organized under cmd/modusgraphgen/internal/ with model, parser, +and generator packages. Test fixtures are self-contained in testdata/. + +The generated code imports github.com/matthewmcneely/modusgraph and +requires no additional dependencies beyond the Go standard library." +``` + +**Step 3: Push feature branch** + +Run: `git push -u origin feature/add-modusgraphgen` + +--- + +### Task 11: Open PR to Upstream + +**Step 1: Create PR to upstream** + +Run: +```bash +gh pr create \ + --repo matthewmcneely/modusgraph \ + --base main \ + --head mlwelles:feature/add-modusgraphgen \ + --title "feat: add modusgraphgen code generator" \ + --body "$(cat <<'EOF' +## Summary + +- Adds `cmd/modusgraphgen/`, a code generation tool that reads Go structs with `json`/`dgraph` tags and produces typed CRUD clients, query builders, auto-paging iterators, functional options, and a Kong CLI +- Zero new dependencies -- the generator uses only the Go standard library (`go/ast`, `go/parser`, `text/template`, `embed`) +- Code organized under `cmd/modusgraphgen/internal/` with `model`, `parser`, and `generator` packages +- Self-contained test fixtures in `testdata/` with golden file regression tests +- Adds "Code Generation" section to README + +## Usage + +```go +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen +``` + +Or install directly: + +```sh +go install github.com/matthewmcneely/modusgraph/cmd/modusgraphgen@latest +``` +EOF +)" +``` + +--- + +### Task 12: Open PR to Fork + +**Step 1: Create PR to fork's main** + +Run: +```bash +gh pr create \ + --repo mlwelles/modusGraph \ + --base main \ + --head feature/add-modusgraphgen \ + --title "feat: add modusgraphgen code generator" \ + --body "$(cat <<'EOF' +## Summary + +- Adds `cmd/modusgraphgen/`, a code generation tool that reads Go structs with `json`/`dgraph` tags and produces typed CRUD clients, query builders, auto-paging iterators, functional options, and a Kong CLI +- Zero new dependencies -- the generator uses only the Go standard library +- Self-contained tests with golden file regression testing +- Adds "Code Generation" section to README + +Mirror of PR opened to upstream (matthewmcneely/modusgraph). Merging here to use while awaiting upstream review. +EOF +)" +```