diff --git a/.gitignore b/.gitignore index a63304e..3b54e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,20 @@ go.work.sum .env cpu_profile.prof + +# IDE config +.idea/ +.vscode/ + +# Tool config directories +.osgrep +.opencode +.claude + +# Benchmark result files +load_test/*.json + +# Built binaries +query +modusgraphgen +modusgraph-gen 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/modusgraph-gen/internal/generator/generator.go b/cmd/modusgraph-gen/internal/generator/generator.go new file mode 100644 index 0000000..0252fb1 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/model" +) + +//go:embed templates/*.tmpl +var templateFS embed.FS + +// header is prepended to every generated file. +const header = "// Code generated by modusGraphGen. DO NOT EDIT.\n\n" + +// generateConfig holds optional configuration for Generate. +type generateConfig struct { + CLIDir string // Absolute path for CLI output; empty means default ({outputDir}/cmd/{name}). +} + +// GenerateOption configures code generation. +type GenerateOption func(*generateConfig) + +// WithCLIDir sets the output directory for the generated CLI main.go. +// When empty (the default), the CLI is generated at {outputDir}/cmd/{packageName}/main.go. +func WithCLIDir(dir string) GenerateOption { + return func(c *generateConfig) { c.CLIDir = dir } +} + +// Generate renders all code-generation templates against pkg and writes the +// resulting Go source files into outputDir. The directory must already exist. +func Generate(pkg *model.Package, outputDir string, opts ...GenerateOption) error { + cfg := &generateConfig{} + for _, opt := range opts { + opt(cfg) + } + + // Default CLIName to the package name if not explicitly set. + if pkg.CLIName == "" { + pkg.CLIName = pkg.Name + } + // Sort entities by name for deterministic output. + sort.Slice(pkg.Entities, func(i, j int) bool { + return pkg.Entities[i].Name < pkg.Entities[j].Name + }) + funcMap := template.FuncMap{ + "toLower": strings.ToLower, + "toUpper": strings.ToUpper, + "toSnakeCase": toSnakeCase, + "toCamelCase": toCamelCase, + "toLowerCamel": toLowerCamel, + "title": strings.Title, //nolint:staticcheck + "hasPrefix": strings.HasPrefix, + "hasSuffix": strings.HasSuffix, + "contains": strings.Contains, + "trimPrefix": strings.TrimPrefix, + "join": strings.Join, + "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + + // Field helpers for templates. + "scalarFields": scalarFields, + "edgeFields": edgeFields, + "searchPredicate": searchPredicate, + "externalImports": externalImports, + } + + tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.tmpl") + if err != nil { + return fmt.Errorf("parsing templates: %w", err) + } + + // 1. client.go.tmpl → client_gen.go (once) + if err := executeAndWrite(tmpl, "client.go.tmpl", pkg, filepath.Join(outputDir, "client_gen.go")); err != nil { + return err + } + + // 2. page_options.go.tmpl → page_options_gen.go (once) + if err := executeAndWrite(tmpl, "page_options.go.tmpl", pkg, filepath.Join(outputDir, "page_options_gen.go")); err != nil { + return err + } + + // 3. iter.go.tmpl → iter_gen.go (once) + if err := executeAndWrite(tmpl, "iter.go.tmpl", pkg, filepath.Join(outputDir, "iter_gen.go")); err != nil { + return err + } + + // Per-entity templates. + type entityData struct { + PackageName string + Entity model.Entity + Entities []model.Entity + Imports map[string]string // Package alias → import path + } + + for _, entity := range pkg.Entities { + data := entityData{ + PackageName: pkg.Name, + Entity: entity, + Entities: pkg.Entities, + Imports: pkg.Imports, + } + snake := toSnakeCase(entity.Name) + + // 4. entity.go.tmpl → _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/modusgraph-gen/internal/generator/generator_test.go b/cmd/modusgraph-gen/internal/generator/generator_test.go new file mode 100644 index 0000000..e90262d --- /dev/null +++ b/cmd/modusgraph-gen/internal/generator/generator_test.go @@ -0,0 +1,693 @@ +package generator + +import ( + "flag" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/model" + "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/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) + } +} + +func TestGeneratedClientHasQueryRaw(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) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "client_gen.go")) + if err != nil { + t.Fatalf("reading client_gen.go: %v", err) + } + content := string(data) + + if !strings.Contains(content, "func (c *Client) QueryRaw(") { + t.Error("client_gen.go should contain QueryRaw method") + } + if !strings.Contains(content, "c.conn.QueryRaw(") { + t.Error("client_gen.go QueryRaw should delegate to c.conn.QueryRaw") + } +} + +func TestGeneratedCLIHasQuerySubcommand(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 have query subcommand. + if !strings.Contains(content, "QueryCmd") { + t.Error("CLI should contain QueryCmd type") + } + if !strings.Contains(content, "Query") || !strings.Contains(content, "QueryCmd") { + t.Error("CLI root should have Query field of type QueryCmd") + } + // Should have --dir flag. + if !strings.Contains(content, `Dir string`) { + t.Error("CLI should have Dir flag") + } + // Should have connectString helper. + if !strings.Contains(content, "func connectString()") { + t.Error("CLI should have connectString function") + } +} + +func TestGeneratedCLIDirAndAddrMutuallyExclusive(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 mutual exclusion logic. + if !strings.Contains(content, `--addr and --dir are mutually exclusive`) { + t.Error("CLI should contain mutual exclusion error message") + } +} diff --git a/cmd/modusgraph-gen/internal/generator/templates/cli.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/cli.go.tmpl new file mode 100644 index 0000000..9f6a89b --- /dev/null +++ b/cmd/modusgraph-gen/internal/generator/templates/cli.go.tmpl @@ -0,0 +1,204 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "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"` + Dir string `help:"Local database directory (embedded mode, mutually exclusive with --addr)." env:"DGRAPH_DIR"` + + Query QueryCmd `cmd:"" help:"Execute a raw DQL query."` +{{- range .Entities}} + {{.Name}} {{.Name}}Cmd `cmd:"" help:"Manage {{.Name}} entities."` +{{- end}} +} + +// QueryCmd executes a raw DQL query against the database. +type QueryCmd struct { + Query string `arg:"" optional:"" help:"DQL query string (reads stdin if omitted)."` + Pretty bool `help:"Pretty-print JSON output." default:"true" negatable:""` + Timeout time.Duration `help:"Query timeout." default:"30s"` +} + +func (c *QueryCmd) Run(client *{{.Name}}.Client) error { + query := c.Query + if query == "" { + // Read from stdin. + reader := bufio.NewReader(os.Stdin) + var sb strings.Builder + for { + line, err := reader.ReadString('\n') + sb.WriteString(line) + if err != nil { + if err != io.EOF { + return fmt.Errorf("reading stdin: %w", err) + } + break + } + } + query = strings.TrimSpace(sb.String()) + } + + if query == "" { + return fmt.Errorf("empty query: provide a DQL query as an argument or via stdin") + } + + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + resp, err := client.QueryRaw(ctx, query, nil) + if err != nil { + return err + } + + if c.Pretty { + var data any + if err := json.Unmarshal(resp, &data); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + _, err = fmt.Println(string(resp)) + return err +} + +{{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 connectString() (string, error) { + if CLI.Dir != "" { + if CLI.Addr != "dgraph://localhost:9080" { + return "", fmt.Errorf("--addr and --dir are mutually exclusive") + } + return fmt.Sprintf("file://%s", filepath.Clean(CLI.Dir)), nil + } + return CLI.Addr, nil +} + +func main() { + ctx := kong.Parse(&CLI, + kong.Name("{{.CLIName}}"), + kong.Description("CLI for the {{.CLIName}} data model."), + ) + + connStr, err := connectString() + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + client, err := {{.Name}}.New(connStr, + 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/modusgraph-gen/internal/generator/templates/client.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/client.go.tmpl new file mode 100644 index 0000000..1deba9e --- /dev/null +++ b/cmd/modusgraph-gen/internal/generator/templates/client.go.tmpl @@ -0,0 +1,46 @@ +package {{.Name}} + +import ( + "context" + + "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}} + } +} + +// QueryRaw executes a raw DQL query against the database. +// The query parameter is the Dgraph query string (DQL syntax). +// The vars parameter is an optional map of variable names to values for parameterized queries. +func (c *Client) QueryRaw(ctx context.Context, query string, vars map[string]string) ([]byte, error) { + return c.conn.QueryRaw(ctx, query, vars) +} + +// Close releases all resources used by the client. +func (c *Client) Close() { + c.conn.Close() +} diff --git a/cmd/modusgraph-gen/internal/generator/templates/entity.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/entity.go.tmpl new file mode 100644 index 0000000..08e9eae --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/templates/iter.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/iter.go.tmpl new file mode 100644 index 0000000..1c9c6e8 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/templates/options.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/options.go.tmpl new file mode 100644 index 0000000..efdbfd5 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/templates/page_options.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/page_options.go.tmpl new file mode 100644 index 0000000..c90e5ac --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/templates/query.go.tmpl b/cmd/modusgraph-gen/internal/generator/templates/query.go.tmpl new file mode 100644 index 0000000..709a9ec --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/actor_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/actor_gen.go new file mode 100644 index 0000000..c0f7b20 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/actor_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/actor_options_gen.go new file mode 100644 index 0000000..2696efb --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/actor_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/actor_query_gen.go new file mode 100644 index 0000000..cde05cd --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/client_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/client_gen.go new file mode 100644 index 0000000..ec1af69 --- /dev/null +++ b/cmd/modusgraph-gen/internal/generator/testdata/golden/client_gen.go @@ -0,0 +1,60 @@ +// Code generated by modusGraphGen. DO NOT EDIT. + +package movies + +import ( + "context" + + "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}, + } +} + +// QueryRaw executes a raw DQL query against the database. +// The query parameter is the Dgraph query string (DQL syntax). +// The vars parameter is an optional map of variable names to values for parameterized queries. +func (c *Client) QueryRaw(ctx context.Context, query string, vars map[string]string) ([]byte, error) { + return c.conn.QueryRaw(ctx, query, vars) +} + +// Close releases all resources used by the client. +func (c *Client) Close() { + c.conn.Close() +} diff --git a/cmd/modusgraph-gen/internal/generator/testdata/golden/content_rating_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/content_rating_gen.go new file mode 100644 index 0000000..5c7bce4 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/content_rating_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/content_rating_options_gen.go new file mode 100644 index 0000000..8fbf429 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/content_rating_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/content_rating_query_gen.go new file mode 100644 index 0000000..c45d3d3 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/country_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/country_gen.go new file mode 100644 index 0000000..8a8f667 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/country_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/country_options_gen.go new file mode 100644 index 0000000..e799d6d --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/country_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/country_query_gen.go new file mode 100644 index 0000000..be873fa --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/director_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/director_gen.go new file mode 100644 index 0000000..3e5c331 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/director_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/director_options_gen.go new file mode 100644 index 0000000..2587d8c --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/director_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/director_query_gen.go new file mode 100644 index 0000000..93be564 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/film_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/film_gen.go new file mode 100644 index 0000000..b240984 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/film_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/film_options_gen.go new file mode 100644 index 0000000..3d84f8f --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/film_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/film_query_gen.go new file mode 100644 index 0000000..6492f22 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/genre_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/genre_gen.go new file mode 100644 index 0000000..46ec8eb --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/genre_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/genre_options_gen.go new file mode 100644 index 0000000..d923ce5 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/genre_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/genre_query_gen.go new file mode 100644 index 0000000..4d42408 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/iter_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/iter_gen.go new file mode 100644 index 0000000..373e84b --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/location_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/location_gen.go new file mode 100644 index 0000000..6d1b0ab --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/location_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/location_options_gen.go new file mode 100644 index 0000000..f782994 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/location_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/location_query_gen.go new file mode 100644 index 0000000..6a2e9cd --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/page_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/page_options_gen.go new file mode 100644 index 0000000..d641294 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/performance_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/performance_gen.go new file mode 100644 index 0000000..fabd32c --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/performance_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/performance_options_gen.go new file mode 100644 index 0000000..50b9d7f --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/performance_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/performance_query_gen.go new file mode 100644 index 0000000..d57976c --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/rating_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/rating_gen.go new file mode 100644 index 0000000..b1db2af --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/rating_options_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/rating_options_gen.go new file mode 100644 index 0000000..c3032ab --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/generator/testdata/golden/rating_query_gen.go b/cmd/modusgraph-gen/internal/generator/testdata/golden/rating_query_gen.go new file mode 100644 index 0000000..1cccaf6 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/model/model.go b/cmd/modusgraph-gen/internal/model/model.go new file mode 100644 index 0000000..9edd01c --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/inference.go b/cmd/modusgraph-gen/internal/parser/inference.go new file mode 100644 index 0000000..7bdcb43 --- /dev/null +++ b/cmd/modusgraph-gen/internal/parser/inference.go @@ -0,0 +1,54 @@ +package parser + +import ( + "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/parser.go b/cmd/modusgraph-gen/internal/parser/parser.go new file mode 100644 index 0000000..753fd08 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/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/modusgraph-gen/internal/parser/parser_test.go b/cmd/modusgraph-gen/internal/parser/parser_test.go new file mode 100644 index 0000000..39fb7ea --- /dev/null +++ b/cmd/modusgraph-gen/internal/parser/parser_test.go @@ -0,0 +1,469 @@ +package parser + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/actor.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/actor.go new file mode 100644 index 0000000..962af83 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/content_rating.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/content_rating.go new file mode 100644 index 0000000..c74b80f --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/country.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/country.go new file mode 100644 index 0000000..827f468 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/director.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/director.go new file mode 100644 index 0000000..e1c4a17 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/film.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/film.go new file mode 100644 index 0000000..4e8f8a7 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/generate.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/generate.go new file mode 100644 index 0000000..ef0db8e --- /dev/null +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/generate.go @@ -0,0 +1,3 @@ +package movies + +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/genre.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/genre.go new file mode 100644 index 0000000..520c1e0 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/go.mod b/cmd/modusgraph-gen/internal/parser/testdata/movies/go.mod new file mode 100644 index 0000000..ab35428 --- /dev/null +++ b/cmd/modusgraph-gen/internal/parser/testdata/movies/go.mod @@ -0,0 +1,3 @@ +module github.com/mlwelles/modusGraphMoviesProject + +go 1.25.6 diff --git a/cmd/modusgraph-gen/internal/parser/testdata/movies/location.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/location.go new file mode 100644 index 0000000..a4f4348 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/performance.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/performance.go new file mode 100644 index 0000000..3187564 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/internal/parser/testdata/movies/rating.go b/cmd/modusgraph-gen/internal/parser/testdata/movies/rating.go new file mode 100644 index 0000000..359d23b --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen/main.go b/cmd/modusgraph-gen/main.go new file mode 100644 index 0000000..2e6a017 --- /dev/null +++ b/cmd/modusgraph-gen/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/modusgraph-gen [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/modusgraph-gen/internal/generator" + "github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/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/cmd/query/main.go b/cmd/query/main.go index 592754b..63e476d 100644 --- a/cmd/query/main.go +++ b/cmd/query/main.go @@ -3,6 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +// Deprecated: Use the generated CLI's "query" subcommand instead. +// Example: movies query '{ q(func: has(name@en)) { uid name@en } }' +// This standalone tool will be removed in a future release. package main import ( @@ -24,6 +27,8 @@ import ( ) func main() { + fmt.Fprintln(os.Stderr, "WARNING: cmd/query is deprecated. Use the generated CLI's 'query' subcommand instead.") + // Define flags dirFlag := flag.String("dir", "", "Directory where the modusGraph database is stored") prettyFlag := flag.Bool("pretty", true, "Pretty-print the JSON output") 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 +)" +``` diff --git a/docs/plans/2026-02-27-merge-query-cli-plan.md b/docs/plans/2026-02-27-merge-query-cli-plan.md new file mode 100644 index 0000000..9ec4e65 --- /dev/null +++ b/docs/plans/2026-02-27-merge-query-cli-plan.md @@ -0,0 +1,731 @@ +# Merge Query into Generated CLI — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Merge the standalone `cmd/query` tool into the code-generated CLI as a `query` subcommand, rename `cmd/modusgraphgen` to `cmd/modusgraph-gen`, add a `QueryRaw` method to the generated client, and update consumer projects. + +**Architecture:** Template-only approach — embed query logic directly in `cli.go.tmpl` and add `QueryRaw` to `client.go.tmpl`. The generator directory moves from `cmd/modusgraphgen` to `cmd/modusgraph-gen`. All connection modes (`--addr` gRPC, `--dir` embedded) become global CLI flags. The standalone `cmd/query` is preserved with a deprecation notice. + +**Tech Stack:** Go, text/template, Kong CLI framework, modusgraph Client interface + +--- + +### Task 1: Rename cmd/modusgraphgen → cmd/modusgraph-gen + +**Files:** +- Rename: `cmd/modusgraphgen/` → `cmd/modusgraph-gen/` +- Modify: `cmd/modusgraph-gen/main.go` (update doc comment) +- Modify: `cmd/modusgraph-gen/internal/generator/generator.go:18` (update import path) +- Modify: `cmd/modusgraph-gen/internal/generator/generator_test.go:12` (update import paths) +- Modify: `cmd/modusgraph-gen/internal/parser/testdata/movies/generate.go` (update go:generate directive) + +**Step 1: Move the directory** + +```bash +git mv cmd/modusgraphgen cmd/modusgraph-gen +``` + +**Step 2: Update the doc comment in main.go** + +In `cmd/modusgraph-gen/main.go`, change line 7: +```go +// Old: +// go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen [flags] +// New: +// go run github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen [flags] +``` + +**Step 3: Update internal import paths** + +In `cmd/modusgraph-gen/main.go`, update imports: +```go +// Old: +"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/generator" +"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser" +// New: +"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/generator" +"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser" +``` + +In `cmd/modusgraph-gen/internal/generator/generator.go:18`, update: +```go +// Old: +"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" +// New: +"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/model" +``` + +In `cmd/modusgraph-gen/internal/generator/generator_test.go:12`, update: +```go +// Old: +"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/model" +"github.com/matthewmcneely/modusgraph/cmd/modusgraphgen/internal/parser" +// New: +"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/model" +"github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen/internal/parser" +``` + +**Step 4: Update test fixture go:generate directive** + +In `cmd/modusgraph-gen/internal/parser/testdata/movies/generate.go`: +```go +// Old: +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraphgen +// New: +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen +``` + +**Step 5: Verify build and tests** + +```bash +go build ./cmd/modusgraph-gen/... +go test ./cmd/modusgraph-gen/... +``` +Expected: Build succeeds, all tests pass. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "refactor: rename cmd/modusgraphgen to cmd/modusgraph-gen" +``` + +--- + +### Task 2: Add deprecation notice to cmd/query + +**Files:** +- Modify: `cmd/query/main.go` + +**Step 1: Add deprecation comment** + +At the top of `cmd/query/main.go`, add after the license header (before `package main`): +```go +// Deprecated: Use the generated CLI's "query" subcommand instead. +// Example: movies query '{ q(func: has(name@en)) { uid name@en } }' +// This standalone tool will be removed in a future release. +``` + +**Step 2: Add runtime deprecation warning** + +At the start of `main()`, before flag parsing, add: +```go +fmt.Fprintln(os.Stderr, "WARNING: cmd/query is deprecated. Use the generated CLI's 'query' subcommand instead.") +``` + +**Step 3: Verify it still works** + +```bash +go build ./cmd/query/... +``` +Expected: Build succeeds. + +**Step 4: Commit** + +```bash +git add cmd/query/main.go && git commit -m "chore: add deprecation notice to standalone cmd/query" +``` + +--- + +### Task 3: Add QueryRaw method to client.go.tmpl + +**Files:** +- Modify: `cmd/modusgraph-gen/internal/generator/templates/client.go.tmpl` + +**Step 1: Update the template** + +Add at the end of `client.go.tmpl`, before the closing (after the `Close()` method): + +``` +// QueryRaw executes a raw DQL query against the database. +// The query parameter is the Dgraph query string (DQL syntax). +// The vars parameter is an optional map of variable names to values for parameterized queries. +func (c *Client) QueryRaw(ctx context.Context, query string, vars map[string]string) ([]byte, error) { + return c.conn.QueryRaw(ctx, query, vars) +} +``` + +Also add `"context"` to the import block in the template: + +```go +import ( + "context" + + "github.com/matthewmcneely/modusgraph" +) +``` + +**Step 2: Verify generation compiles** + +```bash +go test ./cmd/modusgraph-gen/internal/generator/ -run TestGenerateOutputFiles -v +``` +Expected: PASS (generated files still compile, but golden tests will fail — that's expected until we update them). + +**Step 3: Commit** + +```bash +git add cmd/modusgraph-gen/internal/generator/templates/client.go.tmpl +git commit -m "feat: add QueryRaw method to generated client template" +``` + +--- + +### Task 4: Add query subcommand and --dir/--addr flags to cli.go.tmpl + +**Files:** +- Modify: `cmd/modusgraph-gen/internal/generator/templates/cli.go.tmpl` + +**Step 1: Replace the entire cli.go.tmpl with the new version** + +The new template adds: +- `Dir` global flag (mutually exclusive with `Addr`) +- `Query` subcommand with `--pretty`, `--timeout` flags +- `connectString()` helper function +- stdin fallback for query input + +New full `cli.go.tmpl`: + +``` +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "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"` + Dir string `help:"Local database directory (embedded mode, mutually exclusive with --addr)." env:"DGRAPH_DIR"` + + Query QueryCmd `cmd:"" help:"Execute a raw DQL query."` +{{- range .Entities}} + {{.Name}} {{.Name}}Cmd `cmd:"" help:"Manage {{.Name}} entities."` +{{- end}} +} + +// QueryCmd executes a raw DQL query against the database. +type QueryCmd struct { + Query string `arg:"" optional:"" help:"DQL query string (reads stdin if omitted)."` + Pretty bool `help:"Pretty-print JSON output." default:"true" negatable:""` + Timeout time.Duration `help:"Query timeout." default:"30s"` +} + +func (c *QueryCmd) Run(client *{{.Name}}.Client) error { + query := c.Query + if query == "" { + // Read from stdin. + reader := bufio.NewReader(os.Stdin) + var sb strings.Builder + for { + line, err := reader.ReadString('\n') + sb.WriteString(line) + if err != nil { + if err != io.EOF { + return fmt.Errorf("reading stdin: %w", err) + } + break + } + } + query = strings.TrimSpace(sb.String()) + } + + if query == "" { + return fmt.Errorf("empty query: provide a DQL query as an argument or via stdin") + } + + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + resp, err := client.QueryRaw(ctx, query, nil) + if err != nil { + return err + } + + if c.Pretty { + var data any + if err := json.Unmarshal(resp, &data); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + _, err = fmt.Println(string(resp)) + return err +} + +{{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 connectString() (string, error) { + if CLI.Dir != "" { + if CLI.Addr != "dgraph://localhost:9080" { + return "", fmt.Errorf("--addr and --dir are mutually exclusive") + } + return fmt.Sprintf("file://%s", filepath.Clean(CLI.Dir)), nil + } + return CLI.Addr, nil +} + +func main() { + ctx := kong.Parse(&CLI, + kong.Name("{{.CLIName}}"), + kong.Description("CLI for the {{.CLIName}} data model."), + ) + + connStr, err := connectString() + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + client, err := {{.Name}}.New(connStr, + 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) +} +``` + +**Step 2: Verify generation compiles** + +```bash +go test ./cmd/modusgraph-gen/internal/generator/ -run TestGenerateOutputFiles -v +``` +Expected: PASS. + +**Step 3: Commit** + +```bash +git add cmd/modusgraph-gen/internal/generator/templates/cli.go.tmpl +git commit -m "feat: add query subcommand and --dir/--addr flags to generated CLI" +``` + +--- + +### Task 5: Update generator tests and golden files + +**Files:** +- Modify: `cmd/modusgraph-gen/internal/generator/generator_test.go` +- Update: `cmd/modusgraph-gen/internal/generator/testdata/golden/client_gen.go` (via -update flag) + +**Step 1: Add test for QueryRaw in generated client** + +Add this test to `generator_test.go`: + +```go +func TestGeneratedClientHasQueryRaw(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) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "client_gen.go")) + if err != nil { + t.Fatalf("reading client_gen.go: %v", err) + } + content := string(data) + + if !strings.Contains(content, "func (c *Client) QueryRaw(") { + t.Error("client_gen.go should contain QueryRaw method") + } + if !strings.Contains(content, "c.conn.QueryRaw(") { + t.Error("client_gen.go QueryRaw should delegate to c.conn.QueryRaw") + } +} +``` + +**Step 2: Add test for query subcommand in generated CLI** + +```go +func TestGeneratedCLIHasQuerySubcommand(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 have query subcommand. + if !strings.Contains(content, "QueryCmd") { + t.Error("CLI should contain QueryCmd type") + } + if !strings.Contains(content, `Query QueryCmd`) { + t.Error("CLI root should have Query field") + } + // Should have --dir flag. + if !strings.Contains(content, `Dir string`) { + t.Error("CLI should have Dir flag") + } + // Should have connectString helper. + if !strings.Contains(content, "func connectString()") { + t.Error("CLI should have connectString function") + } +} +``` + +**Step 3: Run all tests (they will fail on golden diff)** + +```bash +go test ./cmd/modusgraph-gen/... -v +``` +Expected: New tests PASS, golden test FAILS (expected — golden files are stale). + +**Step 4: Update golden files** + +```bash +go test ./cmd/modusgraph-gen/internal/generator/ -update -v +``` +Expected: Golden files updated successfully. + +**Step 5: Verify all tests pass** + +```bash +go test ./cmd/modusgraph-gen/... -v +``` +Expected: All tests PASS. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "test: add query subcommand tests and update golden files" +``` + +--- + +### Task 6: Push to feature branch and update PR on upstream + +**Step 1: Push changes to origin** + +```bash +git push origin feature/add-modusgraphgen +``` + +**Step 2: Update existing PR #10 on upstream (matthewmcneely/modusgraph)** + +The PR at https://github.com/matthewmcneely/modusgraph/pull/10 should auto-update since we pushed to the same branch. + +Verify: +```bash +gh pr view 10 --repo matthewmcneely/modusgraph +``` + +**Step 3: Create new PR to fork's main branch** + +```bash +gh pr create \ + --repo mlwelles/modusGraph \ + --base main \ + --head feature/add-modusgraphgen \ + --title "feat: merge query command into generated CLI and rename to modusgraph-gen" \ + --body "$(cat <<'EOF' +## Summary +- Renames `cmd/modusgraphgen` to `cmd/modusgraph-gen` +- Adds `query` subcommand to generated CLI (accepts DQL as arg or stdin) +- Adds `QueryRaw` method to generated Go client +- Adds `--dir` flag for embedded Dgraph mode (mutually exclusive with `--addr`) +- Preserves `cmd/query` with deprecation notice +- Updates golden tests + +## Usage +\`\`\`bash +movies query '{ q(func: has(name@en), first: 5) { uid name@en } }' +echo '{ q(func: has(name)) { uid } }' | movies query +movies --dir /tmp/db query '{ q(func: has(name)) { uid } }' +\`\`\` +EOF +)" +``` + +--- + +### Task 7: Update modusGraphMoviesProject to use merged fork + +**Files:** +- Modify: `/Users/mwelles/Developer/mlwelles/modusGraphMoviesProject/go.mod` +- Modify: `/Users/mwelles/Developer/mlwelles/modusGraphMoviesProject/movies/generate.go` +- Regenerate: all `*_gen.go` files and `cmd/movies/main.go` +- Modify: `/Users/mwelles/Developer/mlwelles/modusGraphMoviesProject/README.md` + +**Step 1: Update go.mod replace directive** + +The go.mod needs a replace directive pointing to the updated fork. First, add a tool directive for the renamed generator: + +In `go.mod`, update the tool line: +``` +// Old: +tool github.com/mlwelles/modusGraphGen +// New: +tool github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen +``` + +Remove the old replace for modusGraphGen (no longer needed since generator is now in the modusgraph repo). + +Ensure the modusgraph replace points to the latest fork commit: +``` +replace github.com/matthewmcneely/modusgraph => github.com/mlwelles/modusGraph +``` + +Run `go mod tidy`. + +**Step 2: Update generate.go directive** + +```go +// Old: +//go:generate go run github.com/mlwelles/modusGraphGen +// New: +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen +``` + +**Step 3: Regenerate code** + +```bash +go generate ./movies/... +``` + +**Step 4: Verify build** + +```bash +go build ./... +``` + +**Step 5: Verify the CLI has the query subcommand** + +```bash +go run ./movies/cmd/movies --help +go run ./movies/cmd/movies query --help +``` + +**Step 6: Update README.md** + +Add a section documenting the new `query` subcommand and the dual connection modes. Describe both what standard modusGraph provides (typed CRUD, search, query builders, iterators, validation) and what the new query functionality adds (raw DQL queries via CLI and Go client). + +**Step 7: Run tests** + +```bash +go test ./... -short +``` +Expected: Tests pass (integration tests skip without Dgraph). + +**Step 8: Commit and push** + +```bash +git add -A +git commit -m "feat: switch to modusgraph-gen from modusgraph repo, add query subcommand" +git push origin main +``` + +--- + +### Task 8: Update go-registry-poc to use merged fork + +**Files:** +- Modify: `/Users/mwelles/Developer/istari-digital/go-registry-poc/go.mod` +- Modify: `/Users/mwelles/Developer/istari-digital/go-registry-poc/repository/generate.go` +- Regenerate: all `*_gen.go` files and `cmd/registry/main.go` +- Modify: `/Users/mwelles/Developer/istari-digital/go-registry-poc/README.md` + +**Step 1: Update go.mod** + +Replace the tool and replace directives: +``` +// Remove: +tool github.com/mlwelles/modusGraphGen +replace github.com/mlwelles/modusGraphGen => github.com/mlwelles/modusGraphGen v1.3.0 + +// Add: +tool github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen +``` + +Ensure the modusgraph replace points to the latest fork commit: +``` +replace github.com/matthewmcneely/modusgraph => github.com/mlwelles/modusGraph +``` + +Run `go mod tidy`. + +**Step 2: Update generate.go directive** + +```go +// Old: +//go:generate go run github.com/mlwelles/modusGraphGen -cli-dir ../cmd/registry -cli-name registry -with-validator +// New: +//go:generate go run github.com/matthewmcneely/modusgraph/cmd/modusgraph-gen -cli-dir ../cmd/registry -cli-name registry -with-validator +``` + +**Step 3: Regenerate code** + +```bash +go generate ./repository/... +``` + +**Step 4: Verify build** + +```bash +go build ./... +``` + +**Step 5: Verify the CLI has the query subcommand** + +```bash +go run ./cmd/registry --help +go run ./cmd/registry query --help +``` + +**Step 6: Update README.md** + +Add documentation for the `query` subcommand. Describe standard modusGraph features and the new raw query capability. + +**Step 7: Run tests** + +```bash +go test ./... -short +``` +Expected: Tests pass. + +**Step 8: Commit and push** + +First commit any pending module rename changes, then the new changes: +```bash +git add -A +git commit -m "feat: switch to modusgraph-gen, add query subcommand support" +git push origin main +``` + +--- + +## Task Summary + +| Task | Description | Depends On | +|------|-------------|------------| +| 1 | Rename cmd/modusgraphgen → cmd/modusgraph-gen | — | +| 2 | Deprecate cmd/query | — | +| 3 | Add QueryRaw to client.go.tmpl | 1 | +| 4 | Add query subcommand to cli.go.tmpl | 1, 3 | +| 5 | Update tests and golden files | 3, 4 | +| 6 | Push to feature branch, update PRs | 5 | +| 7 | Update modusGraphMoviesProject | 6 | +| 8 | Update go-registry-poc | 6 | diff --git a/docs/plans/2026-02-27-merge-query-into-generated-cli-design.md b/docs/plans/2026-02-27-merge-query-into-generated-cli-design.md new file mode 100644 index 0000000..265e8f8 --- /dev/null +++ b/docs/plans/2026-02-27-merge-query-into-generated-cli-design.md @@ -0,0 +1,140 @@ +# Design: Merge Query Command into Generated CLI & Client + +**Date:** 2026-02-27 +**Status:** Approved + +## Summary + +Merge the standalone `cmd/query` tool's raw DQL query functionality into the +code-generated CLI as a `query` subcommand. Rename `cmd/modusgraphgen` to +`cmd/modusgraph-gen`. Add a `QueryRaw` method to the generated Go client so the +programmatic API mirrors the CLI. Update consumer projects +(modusGraphMoviesProject, go-registry-poc) to use the merged functionality. + +## Decisions + +| Decision | Choice | +|----------|--------| +| Approach | Template-only: embed query in cli.go.tmpl and client.go.tmpl | +| Generator directory | `cmd/modusgraph-gen` (binary: `modusgraph-gen`) | +| Connection modes | Global `--addr` and `--dir` flags, mutually exclusive | +| Query input | Positional arg with stdin fallback | +| Query flags | `--pretty` (default true), `--timeout` (default 30s) | +| Verbosity | Global `-v` flag on root CLI | +| cmd/query | Preserved with deprecation notice | +| Generated client | Gains `QueryRaw(ctx, query, vars)` method | + +## Directory Structure (After) + +``` +cmd/ +├── modusgraph-gen/ # RENAMED from cmd/modusgraphgen +│ ├── main.go # Binary: modusgraph-gen +│ └── internal/ +│ ├── model/model.go +│ ├── parser/ +│ │ ├── parser.go +│ │ ├── inference.go +│ │ └── testdata/movies/ +│ └── generator/ +│ ├── generator.go +│ └── templates/ +│ ├── cli.go.tmpl # MODIFIED: query subcommand + --dir/--addr +│ ├── client.go.tmpl # MODIFIED: QueryRaw method +│ └── ... (other templates unchanged) +└── query/ + └── main.go # DEPRECATED (preserved for backwards compat) +``` + +## Generated Client API Addition + +```go +// QueryRaw executes a raw DQL query against the database. +func (c *Client) QueryRaw(ctx context.Context, query string, vars map[string]string) ([]byte, error) { + return c.conn.QueryRaw(ctx, query, vars) +} +``` + +## Generated CLI Changes + +### Root CLI Struct + +```go +var CLI struct { + Addr string `help:"Dgraph gRPC address." default:"dgraph://localhost:9080" env:"DGRAPH_ADDR"` + Dir string `help:"Local database directory (embedded mode)." env:"DGRAPH_DIR"` + Query QueryCmd `cmd:"" help:"Execute a raw DQL query."` + // ... entity commands unchanged +} +``` + +### Query Subcommand + +```go +type QueryCmd struct { + Query string `arg:"" optional:"" help:"DQL query string (reads stdin if omitted)."` + Pretty bool `help:"Pretty-print JSON output." default:"true"` + Timeout time.Duration `help:"Query timeout." default:"30s"` +} +``` + +Usage examples: +```bash +movies query '{ q(func: has(name@en), first: 5) { uid name@en } }' +echo '{ q(func: has(name@en)) { uid } }' | movies query +movies query --pretty=false '{ q(func: uid(0x1)) { uid name } }' +movies --dir /tmp/db query '{ q(func: has(name)) { uid } }' +``` + +### Connection Logic + +```go +func connectString() string { + if CLI.Dir != "" { + if CLI.Addr != "dgraph://localhost:9080" { + fmt.Fprintln(os.Stderr, "error: --addr and --dir are mutually exclusive") + os.Exit(1) + } + return fmt.Sprintf("file://%s", filepath.Clean(CLI.Dir)) + } + return CLI.Addr +} +``` + +## Consumer Project Updates + +### modusGraphMoviesProject +- Update `go:generate` to reference `cmd/modusgraph-gen` +- Update go.mod replace directive to point to updated fork +- Re-run `go generate` to regenerate code with query subcommand +- Verify `movies query '...'` works alongside `movies film list` etc. +- Update README with query subcommand documentation + +### go-registry-poc +- Same changes as modusGraphMoviesProject +- Verify `registry query '...'` works alongside `registry resource list` etc. +- Update README with query subcommand documentation + +## Golden Test Impact + +The golden test file `cmd/movies/main.go` will need updating to include the +query subcommand and new `--dir` flag. Run with `-update` flag to regenerate. + +## What This Enables + +After this change, a generated CLI provides: + +**Standard modusGraph generated features:** +- Per-entity CRUD: `get`, `list`, `add`, `delete` +- Per-entity search: `search ` (for entities with fulltext indexes) +- Typed Go client with sub-clients per entity +- Fluent query builders per entity +- Auto-paging iterators (Go 1.23+ `iter.Seq2`) +- Functional options for mutations +- Auto-schema management +- Optional struct validation + +**New query functionality:** +- Raw DQL queries via CLI: ` query ''` +- Raw DQL queries via Go client: `client.QueryRaw(ctx, query, vars)` +- Support for both remote (gRPC) and embedded (file://) Dgraph connections