diff --git a/store/README.md b/store/README.md index 7674001..341c79b 100644 --- a/store/README.md +++ b/store/README.md @@ -16,11 +16,15 @@ go install gofr.dev/cli/gofr@latest 1. **Initialize a store configuration:** ```bash - gofr store init + gofr store init -name=user ``` - This creates a `store.yaml` file with example configuration. + This creates: + - `stores/store.yaml` — central configuration template + - `stores/all.go` — store registry factory (auto-generated) + - `stores/user/interface.go` — initial interface stub + - `stores/user/user.go` — initial implementation stub (editable) -2. **Edit `store.yaml`** with your models and queries (see Configuration Reference below). +2. **Edit `stores/store.yaml`** with your models and queries (see Configuration Reference below). 3. **Generate the store code:** ```bash @@ -30,8 +34,8 @@ go install gofr.dev/cli/gofr@latest 4. **Use in your application:** ```go import "your-project/stores/user" - - userStore := user.NewUser() + + userStore := user.NewUserStore() result, err := userStore.GetUserByID(ctx, 123) ``` @@ -40,24 +44,41 @@ go install gofr.dev/cli/gofr@latest ### Commands ```bash -# Initialize a new store.yaml configuration file -gofr store init +# Initialize a new store directory and store.yaml configuration file +# The -name flag is required +gofr store init -name= -# Generate store code from store.yaml +# Generate store code from a store.yaml +# Default config path: stores/store.yaml gofr store generate + +# Generate store code from a specific config file +gofr store generate -config=custom-store.yaml ``` ### Project Structure -After generation, your project will have: +**After `gofr store init -name=user`:** ``` stores/ -├── all.go # Store registry (auto-generated) -├── user/ -│ ├── interface.go # UserStore interface -│ ├── store.go # UserStore implementation -│ └── user.go # User model (if generated) +├── store.yaml # Central configuration template (edit this) +├── all.go # Store registry factory (auto-generated, DO NOT EDIT) +└── user/ + ├── interface.go # UserStore interface stub (DO NOT EDIT — regenerated by generate) + └── user.go # userStore implementation stub (editable — add your SQL logic here) +``` + +**After `gofr store generate`:** + +``` +stores/ +├── store.yaml # Central configuration (source of truth) +├── all.go # Store registry factory (DO NOT EDIT) +└── user/ + ├── interface.go # UserStore interface (DO NOT EDIT — regenerated each time) + ├── userStore.go # userStore implementation boilerplate (add SQL logic here) + └── user.go # User model (if defined in YAML, DO NOT EDIT) ``` ### Using Generated Stores @@ -66,19 +87,24 @@ stores/ ```go import "your-project/stores/user" -userStore := user.NewUser() +userStore := user.NewUserStore() result, err := userStore.GetUserByID(ctx, id) ``` **Option 2: Using the registry** ```go -import "your-project/stores" +import ( + "your-project/stores" + "your-project/stores/user" +) -allStores := stores.All() -userStore := stores.GetStore("user").(user.User) +// GetStore returns a factory-created instance; cast to the correct interface +userStore := stores.GetStore("user").(user.UserStore) result, err := userStore.GetUserByID(ctx, id) ``` +> **💡 Note:** `stores.All()` returns a `map[string]func() any` — a map of **factory functions**, not active instances. Use `stores.GetStore(name)` for convenient access. + ### Integration Example ```go @@ -91,7 +117,7 @@ import ( func main() { app := gofr.New() - userStore := user.NewUser() + userStore := user.NewUserStore() app.GET("/users/{id}", func(ctx *gofr.Context) (interface{}, error) { id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64) @@ -113,7 +139,7 @@ stores: - name: "user" package: "user" output_dir: "stores/user" - interface: "User" + interface: "UserStore" implementation: "userStore" queries: - name: "GetUserByID" @@ -143,12 +169,14 @@ models: | Field | Description | Required | |-------|-------------|----------| -| `name` | Store identifier (used in registry) | Yes | -| `package` | Go package name | Yes | -| `output_dir` | Directory for generated files | Yes | -| `interface` | Interface name (e.g., "User") | Yes | -| `implementation` | Implementation struct name | Yes | -| `queries` | Array of database queries | Yes | +| `name` | Store identifier (used in registry) | **Yes** | +| `package` | Go package name | **Yes** | +| `output_dir` | Directory for generated files | Optional (defaults to `stores/`) | +| `interface` | Interface name — **recommended: `Store`** (e.g., `UserStore`) | Optional (defaults to `Store`) | +| `implementation` | Implementation struct name (e.g., `userStore`) | Optional (defaults to `Store`) | +| `queries` | Array of database queries | Optional | + +> **⚠️ Naming Convention:** The registry (`stores/all.go`) automatically appends `"Store"` when building constructor calls. To avoid compilation errors, always name your interface as `Store` (e.g., `UserStore`) and the generated constructor will be `NewStore()`. ### Models @@ -185,10 +213,10 @@ models: - `delete` - DELETE queries **Return Types:** -- `single` - Returns a single model instance -- `multiple` - Returns a slice of models -- `count` - Returns `int64` count -- `custom` - Returns `interface{}` +- `single` - Returns `(Model, error)` +- `multiple` - Returns `([]Model, error)` +- `count` - Returns `(int64, error)` +- `custom` - Returns `(any, error)` **Example Query:** ```yaml @@ -206,6 +234,8 @@ queries: ### Multiple Stores +You can define multiple stores in a single YAML file. Each store gets its own directory and the registry (`stores/all.go`) tracks all of them. + ```yaml version: "1.0" @@ -213,14 +243,14 @@ stores: - name: "user" package: "user" output_dir: "stores/user" - interface: "User" + interface: "UserStore" implementation: "userStore" queries: [...] - name: "product" package: "product" output_dir: "stores/product" - interface: "Product" + interface: "ProductStore" implementation: "productStore" queries: [...] @@ -231,6 +261,12 @@ models: fields: [...] ``` +**Accessing multiple stores from the registry:** +```go +userStore := stores.GetStore("user").(user.UserStore) +productStore := stores.GetStore("product").(product.ProductStore) +``` + ## Generated Code Examples ### Interface @@ -240,7 +276,7 @@ package user import "gofr.dev/pkg/gofr" -type User interface { +type UserStore interface { GetUserByID(ctx *gofr.Context, id int64) (User, error) GetAllUsers(ctx *gofr.Context) ([]User, error) } @@ -253,16 +289,20 @@ package user type userStore struct{} -func NewUser() User { +func NewUserStore() UserStore { return &userStore{} } func (s *userStore) GetUserByID(ctx *gofr.Context, id int64) (User, error) { // TODO: Implement query using ctx.SQL() - return User{}, nil + var result User + // err := ctx.SQL().QueryRowContext(ctx, sql, id).Scan(&result.ID, ...) + return result, nil } ``` +> **💡 Note:** The generator creates method **signatures and boilerplate only**. You must implement the actual SQL execution in the `// TODO` sections using `ctx.SQL()` methods. + ### Model ```go // Code generated by gofr.dev/cli/gofr. DO NOT EDIT. @@ -277,4 +317,13 @@ type User struct { func (User) TableName() string { return "user" } -``` \ No newline at end of file +``` + +## Best Practices + +1. **Know Which Files Are Auto-Generated**: Only `interface.go` and `all.go` are marked `DO NOT EDIT` and are overwritten on every `gofr store generate`. The implementation stub (`.go` or `Store.go`) created by `gofr store init` is editable — this is where you add your SQL logic. +2. **Use `Store` Interface Names**: This ensures the registry and constructor align correctly. +3. **Commit your YAML**: Treat `store.yaml` as source of truth. Re-run `gofr store generate` after every change. +4. **Reference Existing Models**: If you already have model structs, use the `path` + `package` fields to avoid duplication. + +For a complete working example, see [`store/example.yaml`](./example.yaml). \ No newline at end of file diff --git a/store/generator.go b/store/generator.go index de855c7..c792ff6 100644 --- a/store/generator.go +++ b/store/generator.go @@ -4,10 +4,6 @@ import ( "bytes" "errors" "fmt" - "go/ast" - "go/format" - "go/parser" - "go/token" "os" "path/filepath" "regexp" @@ -18,7 +14,6 @@ import ( "gofr.dev/pkg/gofr" "golang.org/x/text/cases" "golang.org/x/text/language" - "golang.org/x/tools/go/ast/astutil" "gopkg.in/yaml.v3" ) @@ -44,10 +39,6 @@ var ( errEmptyStoreName = errors.New("store name cannot be empty") errEmptyPackageName = errors.New("package name cannot be empty") errInvalidIdentifier = errors.New("identifier must start with letter or underscore") - errAllFunctionNotFound = errors.New("All() function not found") - errMapLiteralNotFound = errors.New("map literal not found in All() function") - errMapClosingBrace = errors.New("could not find map closing brace") - errMapLiteralInFile = errors.New("map literal not found") ) var storeRegex = regexp.MustCompile(`(?m)\s*"([^"]+)"\s*:\s*func\s*\(\s*\)\s*any\s*\{`) @@ -141,7 +132,7 @@ func InitStore(ctx *gofr.Context) (any, error) { return nil, fmt.Errorf("failed to create store directory: %w", err) } - if err := generateStoreConfig(ctx, storeName, storeDir); err != nil { + if err := generateStoreConfig(ctx, storeName, "stores"); err != nil { return nil, fmt.Errorf("failed to generate store config: %w", err) } @@ -196,10 +187,15 @@ func GenerateStore(ctx *gofr.Context) (any, error) { newStores := make([]Entry, 0, len(cfg.Stores)) for i := range cfg.Stores { + interfaceName := cfg.Stores[i].Interface + if interfaceName == "" { + interfaceName = cases.Title(language.English).String(cfg.Stores[i].Name) + "Store" + } + newStores = append(newStores, Entry{ Name: cfg.Stores[i].Name, - PackageName: strings.ToLower(cfg.Stores[i].Name), - InterfaceName: cases.Title(language.English).String(cfg.Stores[i].Name) + "Store", + PackageName: cfg.Stores[i].Package, + InterfaceName: interfaceName, }) } @@ -302,6 +298,11 @@ func generateSingleStore(ctx *gofr.Context, cfg *Config, store *Info) error { return fmt.Errorf("failed to create output directory: %w", err) } + // Default implementation name if empty + if store.Implementation == "" { + store.Implementation = strings.ToLower(store.Name) + "Store" + } + storeConfig := &Config{ Version: cfg.Version, Models: cfg.Models, @@ -652,9 +653,15 @@ func generateModelFile(ctx *gofr.Context, modelFile string, store *Info, model * return nil } -// generateStoreConfig creates the initial store.yaml configuration file. -func generateStoreConfig(ctx *gofr.Context, storeName, storeDir string) error { - configFile := filepath.Join(storeDir, "store.yaml") +// generateStoreConfig creates the initial store.yaml configuration file or appends to an existing one. +func generateStoreConfig(ctx *gofr.Context, storeName, storesDir string) error { + configFile := filepath.Join(storesDir, "store.yaml") + storeDir := fmt.Sprintf("stores/%s", strings.ToLower(storeName)) + + // If file exists, append to it + if _, err := os.Stat(configFile); err == nil { + return appendToStoreConfig(ctx, configFile, storeName, storeDir) + } t, err := template.New("config").Parse(StoreConfigTemplate) if err != nil { @@ -668,15 +675,17 @@ func generateStoreConfig(ctx *gofr.Context, storeName, storeDir string) error { defer file.Close() data := struct { + StoreName string PackageName string OutputDir string InterfaceName string ImplementationName string }{ + StoreName: storeName, PackageName: strings.ToLower(storeName), OutputDir: storeDir, InterfaceName: cases.Title(language.English).String(storeName) + "Store", - ImplementationName: strings.ToLower(storeName), + ImplementationName: strings.ToLower(storeName) + "Store", } if err := t.Execute(file, data); err != nil { @@ -688,6 +697,44 @@ func generateStoreConfig(ctx *gofr.Context, storeName, storeDir string) error { return nil } +// appendToStoreConfig appends a new store to the existing store.yaml. +func appendToStoreConfig(ctx *gofr.Context, configFile, storeName, storeDir string) error { + cfg, err := parseConfigFile(ctx, configFile) + if err != nil { + return err + } + + // Check if store already exists + for _, s := range cfg.Stores { + if s.Name == storeName { + return nil // Already exists + } + } + + newStore := Info{ + Name: storeName, + Package: strings.ToLower(storeName), + OutputDir: storeDir, + Interface: cases.Title(language.English).String(storeName) + "Store", + Implementation: strings.ToLower(storeName) + "Store", + } + + cfg.Stores = append(cfg.Stores, newStore) + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configFile, data, defaultFilePerm); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + ctx.Logger.Infof("Appended store %s to config file: %s", storeName, configFile) + + return nil +} + // generateInitialInterface creates the initial interface.go file. func generateInitialInterface(ctx *gofr.Context, storeName, storeDir string) error { interfaceFile := filepath.Join(storeDir, "interface.go") @@ -743,7 +790,7 @@ func generateInitialStore(ctx *gofr.Context, storeName, storeDir string) error { InterfaceName string }{ PackageName: strings.ToLower(storeName), - ImplementationName: strings.ToLower(storeName), + ImplementationName: strings.ToLower(storeName) + "Store", InterfaceName: cases.Title(language.English).String(storeName) + "Store", } @@ -775,229 +822,55 @@ func appendStoreEntries(ctx *gofr.Context, newStores []Entry) error { func processExistingAllFile(ctx *gofr.Context, content []byte, newStores []Entry, projectModule string) error { lines := strings.Split(string(content), "\n") - existingStores, existingImports := parseExistingAllFile(lines) - storesToAdd, importsToAdd := filterNewStores(newStores, existingStores, existingImports, projectModule) - - if len(storesToAdd) == 0 { - ctx.Logger.Info("All stores already exist in all.go") - return nil - } + existingStores, _ := parseExistingAllFile(lines) - return updateAllFileWithNewStores(ctx, lines, storesToAdd, importsToAdd, existingStores, projectModule) -} - -// filterNewStores filters out stores that already exist. -func filterNewStores(newStores []Entry, existingStores, existingImports map[string]bool, - projectModule string) (filtered []Entry, importsToAdd []string) { - filtered = make([]Entry, 0, len(newStores)) - importsToAdd = make([]string, 0, len(newStores)) - - for i := range newStores { - store := &newStores[i] - if !existingStores[store.Name] { - filtered = append(filtered, *store) - importPath := fmt.Sprintf(` "%s/stores/%s"`, projectModule, store.PackageName) - - if !existingImports[importPath] { - importsToAdd = append(importsToAdd, importPath) - } - } - } - - return filtered, importsToAdd -} - -// updateAllFileWithNewStores updates the all.go file using AST. -func updateAllFileWithNewStores(ctx *gofr.Context, lines []string, - storesToAdd []Entry, importsToAdd []string, - existingStores map[string]bool, projectModule string) error { - content := strings.Join(lines, "\n") - fset := token.NewFileSet() - - file, err := parser.ParseFile(fset, allStoresFile, content, parser.ParseComments) - if err != nil { - ctx.Logger.Warnf("AST parsing failed, falling back to string-based approach: %v", err) - - return updateAllFileWithNewStoresStringBased(ctx, lines, storesToAdd, - importsToAdd, existingStores, projectModule) - } - - for _, imp := range importsToAdd { - importPath := canonicalizeImport(imp) - if importPath != "" { - astutil.AddImport(fset, file, importPath) - } - } - - mapInsertPos, err := findMapInsertionPointAST(fset, file) - if err != nil { - ctx.Logger.Warnf("Could not find map insertion point using AST: %v", err) - return regenerateCompleteAllFile(ctx, existingStores, storesToAdd, projectModule) - } - - storeEntries := generateStoreEntriesAST(storesToAdd) - if err := insertStoreEntriesAST(fset, file, mapInsertPos, storeEntries); err != nil { - ctx.Logger.Warnf("Could not insert store entries using AST: %v", err) - return regenerateCompleteAllFile(ctx, existingStores, storesToAdd, projectModule) - } - - var buf bytes.Buffer - if err := format.Node(&buf, fset, file); err != nil { - return fmt.Errorf("failed to format AST: %w", err) - } - - if err := os.WriteFile(allStoresFile, buf.Bytes(), defaultFilePerm); err != nil { - return fmt.Errorf("failed to write updated all.go: %w", err) - } - - ctx.Logger.Infof("Appended %d new stores to all.go with their imports", len(storesToAdd)) - - return nil -} - -// updateAllFileWithNewStoresStringBased is the fallback implementation. -func updateAllFileWithNewStoresStringBased(ctx *gofr.Context, lines []string, - storesToAdd []Entry, importsToAdd []string, - existingStores map[string]bool, projectModule string) error { - lines = handleImportSection(lines, importsToAdd) - mapInsertIdx := findMapInsertionPoint(lines) - - if mapInsertIdx == -1 { - mapInsertIdx = findMapInsertionPointAlternative(lines) - } - - if mapInsertIdx == -1 { - ctx.Logger.Warn("Could not find insertion point, regenerating entire all.go file") - return regenerateCompleteAllFile(ctx, existingStores, storesToAdd, projectModule) - } - - storeEntries := buildStoreEntries(storesToAdd) - lines = insertLines(lines, mapInsertIdx, storeEntries) - updatedContent := strings.Join(lines, "\n") - - if err := os.WriteFile(allStoresFile, []byte(updatedContent), defaultFilePerm); err != nil { - return fmt.Errorf("failed to write updated all.go: %w", err) - } - - ctx.Logger.Infof("Appended %d new stores to all.go with their imports", len(storesToAdd)) - - return nil -} - -// buildStoreEntries builds store entry strings. -func buildStoreEntries(storesToAdd []Entry) []string { - entries := make([]string, 0, len(storesToAdd)*linesPerStoreEntry) - - for i := range storesToAdd { - store := &storesToAdd[i] - entries = append(entries, - fmt.Sprintf(` %q: func() any {`, store.Name), - fmt.Sprintf(` return %s.New%s()`, store.PackageName, store.InterfaceName), - ` },`) - } - - return entries -} - -// regenerateCompleteAllFile regenerates the complete all.go file. -func regenerateCompleteAllFile(ctx *gofr.Context, existingStores map[string]bool, - storesToAdd []Entry, projectModule string) error { - allStores := make([]Entry, 0, len(existingStores)+len(storesToAdd)) + // Build the merged list: existing stores + genuinely new ones + merged := make([]Entry, 0, len(existingStores)+len(newStores)) for storeName := range existingStores { - allStores = append(allStores, Entry{ + merged = append(merged, Entry{ Name: storeName, PackageName: storeName, InterfaceName: cases.Title(language.English).String(storeName) + "Store", }) } - allStores = append(allStores, storesToAdd...) - - return generateCompleteAllFile(ctx, allStores, projectModule) -} - -// handleImportSection adds import section if missing. -func handleImportSection(lines, importsToAdd []string) []string { - if len(importsToAdd) == 0 { - return lines - } - - importInsertIdx := findImportInsertionPoint(lines) - if importInsertIdx > 0 { - formattedImports := formatImports(importsToAdd) - return insertLines(lines, importInsertIdx, formattedImports) - } - - return createImportSection(lines, importsToAdd) -} - -// createImportSection creates a new import section. -func createImportSection(lines, importsToAdd []string) []string { - insertIdx := -1 - - for i, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), "package ") { - insertIdx = i + 1 - break + for i := range newStores { + if !existingStores[newStores[i].Name] { + merged = append(merged, newStores[i]) } } - if insertIdx == -1 { - insertIdx = 1 - } - - importSection := []string{""} - if len(importsToAdd) > 0 { - importSection = append(importSection, "import (") - formattedImports := formatImports(importsToAdd) - importSection = append(importSection, formattedImports...) - importSection = append(importSection, ")") - } - - return insertLines(lines, insertIdx, importSection) -} - -// formatImports formats a list of imports. -func formatImports(importsToAdd []string) []string { - formatted := make([]string, len(importsToAdd)) - - for i, imp := range importsToAdd { - formattedImp := strings.TrimSpace(imp) - if !strings.HasPrefix(formattedImp, `"`) { - formattedImp = fmt.Sprintf("%q", formattedImp) - } - - formatted[i] = fmt.Sprintf(` %s`, formattedImp) + if len(merged) == len(existingStores) { + ctx.Logger.Info("All stores already exist in all.go") + return nil } - return formatted + return generateCompleteAllFile(ctx, merged, projectModule) } // parseExistingAllFile parses the existing all.go file. func parseExistingAllFile(lines []string) (existingStores, existingImports map[string]bool) { existingStores = make(map[string]bool) existingImports = make(map[string]bool) - inImportSection := false + + inImport := false for _, line := range lines { trimmedLine := strings.TrimSpace(line) - if strings.Contains(trimmedLine, "import (") { - inImportSection = true + if trimmedLine == "import (" { + inImport = true continue } - if inImportSection { - if trimmedLine == ")" { - inImportSection = false - continue - } - - if strings.Contains(trimmedLine, `"`) { - existingImports[strings.TrimSpace(trimmedLine)] = true - } + if inImport && trimmedLine == ")" { + inImport = false + continue + } + if inImport { + existingImports[trimmedLine] = true continue } @@ -1010,300 +883,8 @@ func parseExistingAllFile(lines []string) (existingStores, existingImports map[s return existingStores, existingImports } -// findImportInsertionPoint finds where to insert new import statements. -func findImportInsertionPoint(lines []string) int { - for i, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if trimmedLine == ")" { - for j := i - 1; j >= 0; j-- { - if strings.Contains(lines[j], "import (") { - return i - } - } - } - } - - return -1 -} - -// findMapInsertionPointAST finds the insertion point using AST. -func findMapInsertionPointAST(_ *token.FileSet, file *ast.File) (token.Pos, error) { - allFunc := findAllFunction(file) - if allFunc == nil { - return token.NoPos, errAllFunctionNotFound - } - - mapLit := findMapLiteral(allFunc) - if mapLit == nil { - return token.NoPos, errMapLiteralNotFound - } - - if mapLit.Rbrace.IsValid() { - return mapLit.Rbrace, nil - } - - return token.NoPos, errMapClosingBrace -} - -// findAllFunction finds the All function in the AST. -func findAllFunction(file *ast.File) *ast.FuncDecl { - for _, decl := range file.Decls { - if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == allFunctionName { - return fn - } - } - - return nil -} - -// findMapLiteral finds the map literal in the All function. -func findMapLiteral(allFunc *ast.FuncDecl) *ast.CompositeLit { - var mapLit *ast.CompositeLit - - ast.Inspect(allFunc.Body, func(n ast.Node) bool { - ret, ok := n.(*ast.ReturnStmt) - if !ok || len(ret.Results) == 0 { - return true - } - - compLit, ok := ret.Results[0].(*ast.CompositeLit) - if !ok { - return true - } - - if !isValidMapLiteral(compLit) { - return true - } - - mapLit = compLit - - return false - }) - - return mapLit -} - -// isValidMapLiteral checks if a composite literal is a valid map[string]func() any. -func isValidMapLiteral(compLit *ast.CompositeLit) bool { - mapType, ok := compLit.Type.(*ast.MapType) - if !ok { - return false - } - - keyType, ok := mapType.Key.(*ast.Ident) - if !ok || keyType.Name != stringType { - return false - } - - funcType, ok := mapType.Value.(*ast.FuncType) - if !ok || funcType.Results == nil || len(funcType.Results.List) != 1 { - return false - } - - resultType, ok := funcType.Results.List[0].Type.(*ast.Ident) - - return ok && resultType.Name == "any" -} - -// generateStoreEntriesAST generates AST key-value expressions. -func generateStoreEntriesAST(storesToAdd []Entry) []ast.KeyValueExpr { - entries := make([]ast.KeyValueExpr, 0, len(storesToAdd)) - - for i := range storesToAdd { - store := &storesToAdd[i] - entry := createStoreEntry(store) - entries = append(entries, entry) - } - - return entries -} - -// createStoreEntry creates a single store AST entry. -func createStoreEntry(store *Entry) ast.KeyValueExpr { - key := &ast.BasicLit{ - Kind: token.STRING, - Value: fmt.Sprintf(`%q`, store.Name), - } - - callExpr := &ast.CallExpr{ - Fun: &ast.SelectorExpr{ - X: &ast.Ident{Name: store.PackageName}, - Sel: &ast.Ident{Name: fmt.Sprintf("New%s", store.InterfaceName)}, - }, - } - - returnStmt := &ast.ReturnStmt{ - Results: []ast.Expr{callExpr}, - } - - funcLit := &ast.FuncLit{ - Type: &ast.FuncType{ - Results: &ast.FieldList{ - List: []*ast.Field{ - {Type: &ast.Ident{Name: "any"}}, - }, - }, - }, - Body: &ast.BlockStmt{ - List: []ast.Stmt{returnStmt}, - }, - } - - return ast.KeyValueExpr{ - Key: key, - Value: funcLit, - } -} - -// insertStoreEntriesAST inserts store entries into the map literal. -func insertStoreEntriesAST(_ *token.FileSet, file *ast.File, - _ token.Pos, entries []ast.KeyValueExpr) error { - mapLit := findMapInFile(file) - if mapLit == nil { - return errMapLiteralInFile - } - - exprs := make([]ast.Expr, len(entries)) - for i := range entries { - exprs[i] = &entries[i] - } - - if mapLit.Elts == nil { - mapLit.Elts = exprs - } else { - mapLit.Elts = append(mapLit.Elts, exprs...) - } - - return nil -} - -// findMapInFile finds the map literal in the file. -func findMapInFile(file *ast.File) *ast.CompositeLit { - var mapLit *ast.CompositeLit - - ast.Inspect(file, func(n ast.Node) bool { - fn, ok := n.(*ast.FuncDecl) - if !ok || fn.Name.Name != allFunctionName { - return true - } - - ast.Inspect(fn.Body, func(n ast.Node) bool { - ret, ok := n.(*ast.ReturnStmt) - if !ok || len(ret.Results) == 0 { - return true - } - - compLit, ok := ret.Results[0].(*ast.CompositeLit) - if !ok { - return true - } - - if !isValidMapLiteral(compLit) { - return true - } - - mapLit = compLit - - return false - }) - - return false - }) - - return mapLit -} - -// findMapInsertionPoint finds where to insert new store entries. -func findMapInsertionPoint(lines []string) int { - mapStartFound := false - braceDepth := 0 - - for i, line := range lines { - if shouldStartMapTracking(line) { - mapStartFound = true - } - - if mapStartFound { - braceDepth = updateBraceDepth(line, braceDepth) - if isMapClosingBrace(line, braceDepth) { - return i - } - } - } - - return -1 -} - -// shouldStartMapTracking determines if we should start tracking the map. -func shouldStartMapTracking(line string) bool { - return strings.Contains(line, "func All()") || - strings.Contains(line, "return map[string]func() any") || - strings.Contains(line, "return map[string]func()any") -} - -// updateBraceDepth updates the brace depth counter. -func updateBraceDepth(line string, currentDepth int) int { - openBraces := strings.Count(line, "{") - closeBraces := strings.Count(line, "}") - - return currentDepth + openBraces - closeBraces -} - -// isMapClosingBrace checks if the current line is the map's closing brace. -func isMapClosingBrace(line string, braceDepth int) bool { - trimmedLine := strings.TrimSpace(line) - - return braceDepth > 0 && (trimmedLine == "}" || - (strings.HasSuffix(trimmedLine, "}") && - !strings.Contains(trimmedLine, "{") && - !strings.Contains(trimmedLine, "func"))) -} - -// findMapInsertionPointAlternative is an alternative method. -func findMapInsertionPointAlternative(lines []string) int { - inAllFunction := false - inMapReturn := false - - for i, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if strings.Contains(line, "func All()") { - inAllFunction = true - continue - } - - if inAllFunction && (strings.Contains(line, "return map[string]func() any") || - strings.Contains(line, "return map[string]func()any")) { - inMapReturn = true - continue - } - - if inMapReturn { - if trimmedLine == "}" || (strings.HasPrefix(trimmedLine, "}") && len(trimmedLine) <= 3) { - return i - } - } - } - - return -1 -} - -// insertLines inserts new lines at the specified index. -func insertLines(lines []string, insertIdx int, newLines []string) []string { - if insertIdx < 0 || insertIdx > len(lines) { - return lines - } - - result := make([]string, 0, len(lines)+len(newLines)) - result = append(result, lines[:insertIdx]...) - result = append(result, newLines...) - result = append(result, lines[insertIdx:]...) - - return result -} - // generateCompleteAllFile generates a complete all.go file from scratch. + func generateCompleteAllFile(ctx *gofr.Context, stores []Entry, projectModule string) error { if err := os.MkdirAll("stores", defaultDirPerm); err != nil { return fmt.Errorf("failed to create stores directory: %w", err) diff --git a/store/generator_test.go b/store/generator_test.go index c08e463..7838d62 100644 --- a/store/generator_test.go +++ b/store/generator_test.go @@ -605,51 +605,6 @@ func All() map[string]func() any { assert.True(t, stores["user"], "store 'user' should be found") } -func Test_filterNewStores(t *testing.T) { - existingStores := map[string]bool{ - "user": true, - } - - existingImports := map[string]bool{ - "test-project/stores/user": true, - } - - newStores := []Entry{ - { - Name: "user", // Already exists - PackageName: "user", - InterfaceName: "User", - }, - { - Name: "product", // New - PackageName: "product", - InterfaceName: "Product", - }, - { - Name: "order", // New - PackageName: "order", - InterfaceName: "Order", - }, - } - - storesToAdd, importsToAdd := filterNewStores(newStores, existingStores, existingImports, "test-project") - - // Should only add product and order - assert.Len(t, storesToAdd, 2) - - names := make(map[string]bool) - for _, store := range storesToAdd { - names[store.Name] = true - } - - assert.True(t, names["product"]) - assert.True(t, names["order"]) - assert.False(t, names["user"]) - - // Should have imports for new stores - assert.NotEmpty(t, importsToAdd) -} - func Test_appendStoreEntries_NewFile(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -664,7 +619,7 @@ func Test_appendStoreEntries_NewFile(t *testing.T) { { Name: "user", PackageName: "user", - InterfaceName: "User", + InterfaceName: "UserStore", }, } @@ -681,7 +636,7 @@ func Test_appendStoreEntries_NewFile(t *testing.T) { contentStr := string(content) assert.Contains(t, contentStr, "Code generated by gofr.dev/cli/gofr. DO NOT EDIT.") assert.Contains(t, contentStr, `"user": func() any`) - assert.Contains(t, contentStr, "user.NewUser()") + assert.Contains(t, contentStr, "user.NewUserStore()") } func Test_appendStoreEntries_ExistingFile(t *testing.T) { @@ -721,12 +676,12 @@ func All() map[string]func() any { { Name: "user", PackageName: "user", - InterfaceName: "User", + InterfaceName: "UserStore", }, { Name: "product", PackageName: "product", - InterfaceName: "Product", + InterfaceName: "ProductStore", }, } @@ -738,13 +693,14 @@ func All() map[string]func() any { require.NoError(t, err) contentStr := string(content) - // Should still have user (already existed) + // Should still have user (already existed, regenerated with correct naming) assert.Contains(t, contentStr, `"user": func() any`) - assert.Contains(t, contentStr, "user.NewUser()") + assert.Contains(t, contentStr, "user.NewUserStore()") // Should have product added (new) assert.Contains(t, contentStr, `"product": func() any`) - assert.Contains(t, contentStr, "product.NewProduct()") - // Should have product import + assert.Contains(t, contentStr, "product.NewProductStore()") + // Should have both imports + assert.Contains(t, contentStr, `"test-project/stores/user"`) assert.Contains(t, contentStr, `"test-project/stores/product"`) } @@ -798,81 +754,6 @@ func Test_storeRegex(t *testing.T) { } } -func Test_findMapInsertionPointString(t *testing.T) { - content := `package stores - -func All() map[string]func() any { - return map[string]func() any { - "user": func() any { - return user.NewUser() - }, - } -} -` - - lines := strings.Split(content, "\n") - insertionPoint := findMapInsertionPoint(lines) - - // Should find the position before the closing brace - assert.Positive(t, insertionPoint) - assert.Less(t, insertionPoint, len(lines)) -} - -func Test_addImportsToFile(t *testing.T) { - tests := []struct { - name string - lines []string - importsToAdd []string - wantContains []string - }{ - { - name: "add imports to existing import block", - lines: []string{ - "package stores", - "", - "import (", - ` "test-project/stores/user"`, - ")", - "", - "func All() map[string]func() any {", - " return map[string]func() any {}", - "}", - }, - importsToAdd: []string{"test-project/stores/product"}, - wantContains: []string{ - `"test-project/stores/user"`, - `"test-project/stores/product"`, - }, - }, - { - name: "add import block when none exists", - lines: []string{ - "package stores", - "", - "func All() map[string]func() any {", - " return map[string]func() any {}", - "}", - }, - importsToAdd: []string{"test-project/stores/user"}, - wantContains: []string{ - "import (", - `"test-project/stores/user"`, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := handleImportSection(tt.lines, tt.importsToAdd) - resultStr := strings.Join(result, "\n") - - for _, want := range tt.wantContains { - assert.Contains(t, resultStr, want) - } - }) - } -} - func Test_generateModels(t *testing.T) { tmpDir := t.TempDir() ctx := createTestContext() @@ -1138,3 +1019,467 @@ func Test_createNewAllFile(t *testing.T) { assert.Contains(t, contentStr, "user.NewUser()") assert.Contains(t, contentStr, "product.NewProduct()") } + +func Test_InitStore(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + c := container.NewContainer(gofrConfig.NewEnvFile("", logging.NewMockLogger(logging.DEBUG))) + req := cmd.NewRequest([]string{"executable", "store", "init", "-name=user"}) + + ctx := &gofr.Context{ + Context: req.Context(), + Request: req, + Container: c, + } + + _, err := InitStore(ctx) + require.NoError(t, err) + + // Verify stores directory structure + assert.DirExists(t, "stores") + assert.DirExists(t, filepath.Join("stores", "user")) + + // Verify store.yaml is in stores/ directory + assert.FileExists(t, filepath.Join("stores", "store.yaml")) + + // Verify store.yaml content + content, err := os.ReadFile(filepath.Join("stores", "store.yaml")) + require.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "user") + assert.Contains(t, contentStr, "stores/user") + + // Verify initial interface and implementation are in stores/user/ + assert.FileExists(t, filepath.Join("stores", "user", "interface.go")) + assert.FileExists(t, filepath.Join("stores", "user", "user.go")) +} + +func Test_InitStore_Append(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + c := container.NewContainer(gofrConfig.NewEnvFile("", logging.NewMockLogger(logging.DEBUG))) + + // Initial store + req1 := cmd.NewRequest([]string{"executable", "store", "init", "-name=user"}) + ctx1 := &gofr.Context{Context: req1.Context(), Request: req1, Container: c} + _, err := InitStore(ctx1) + require.NoError(t, err) + + // Append second store + req2 := cmd.NewRequest([]string{"executable", "store", "init", "-name=product"}) + ctx2 := &gofr.Context{Context: req2.Context(), Request: req2, Container: c} + _, err = InitStore(ctx2) + require.NoError(t, err) + + // Verify both exist in store.yaml + content, err := os.ReadFile(filepath.Join("stores", "store.yaml")) + require.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "name: user") + assert.Contains(t, contentStr, "name: product") +} + +func Test_InitStore_InvalidName(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + c := container.NewContainer(gofrConfig.NewEnvFile("", logging.NewMockLogger(logging.DEBUG))) + req := cmd.NewRequest([]string{"executable", "store", "init", "-name=123store"}) + ctx := &gofr.Context{Context: req.Context(), Request: req, Container: c} + + _, err := InitStore(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid store name") + + // Verify no store directory was created + assert.NoDirExists(t, filepath.Join("stores", "123store")) +} + +type generateStoreTestCase struct { + name string + yamlContent string + configFlag string + wantErr bool + errContains string + verify func(t *testing.T, dir string) +} + +const ( + yamlSingleStoreSelectSingle = `version: "1.0" +models: + - name: "User" + fields: + - name: "ID" + type: "int64" + tag: 'db:"id" json:"id"' + - name: "Name" + type: "string" + tag: 'db:"name" json:"name"' +stores: + - name: "user" + package: "user" + output_dir: "stores/user" + interface: "UserStore" + implementation: "userStore" + queries: + - name: "GetUserByID" + sql: "SELECT id, name FROM users WHERE id = ?" + type: "select" + model: "User" + returns: "single" + params: + - name: "id" + type: "int64" +` + + yamlSingleStoreMultipleReturnTypes = `version: "1.0" +models: + - name: "Product" + fields: + - name: "ID" + type: "int64" + tag: 'db:"id"' + - name: "Name" + type: "string" + tag: 'db:"name"' +stores: + - name: "product" + package: "product" + output_dir: "stores/product" + interface: "ProductStore" + implementation: "productStore" + queries: + - name: "GetByID" + sql: "SELECT * FROM products WHERE id = ?" + type: "select" + model: "Product" + returns: "single" + params: + - name: "id" + type: "int64" + - name: "ListAll" + sql: "SELECT * FROM products" + type: "select" + model: "Product" + returns: "multiple" + - name: "CountActive" + sql: "SELECT COUNT(*) FROM products WHERE active = 1" + type: "select" + model: "Product" + returns: "count" + - name: "CreateProduct" + sql: "INSERT INTO products (name) VALUES (?)" + type: "insert" + model: "Product" + returns: "custom" + params: + - name: "name" + type: "string" +` + + yamlMultipleStores = `version: "1.0" +models: + - name: "User" + fields: + - name: "ID" + type: "int64" + tag: 'db:"id"' + - name: "Order" + fields: + - name: "ID" + type: "int64" + tag: 'db:"id"' +stores: + - name: "user" + package: "user" + output_dir: "stores/user" + interface: "UserStore" + implementation: "userStore" + queries: + - name: "GetByID" + sql: "SELECT id FROM users WHERE id = ?" + type: "select" + model: "User" + returns: "single" + params: + - name: "id" + type: "int64" + - name: "order" + package: "order" + output_dir: "stores/order" + interface: "OrderStore" + implementation: "orderStore" + queries: + - name: "GetByID" + sql: "SELECT id FROM orders WHERE id = ?" + type: "select" + model: "Order" + returns: "single" + params: + - name: "id" + type: "int64" +` + + yamlEmptyQueries = `version: "1.0" +stores: + - name: "empty" + package: "empty" + output_dir: "stores/empty" + interface: "EmptyStore" + implementation: "emptyStore" + queries: [] +` + + yamlCustomConfigStore = `version: "1.0" +stores: + - name: "custom" + package: "custom" + output_dir: "stores/custom" + interface: "CustomStore" + implementation: "customStore" + queries: + - name: "GetAll" + sql: "SELECT * FROM custom" + type: "select" + model: "Custom" + returns: "multiple" +` + + yamlExternalModelReference = `version: "1.0" +models: + - name: "User" + path: "../models/user.go" + package: "test-project/models" +stores: + - name: "user" + package: "user" + output_dir: "stores/user" + interface: "UserStore" + implementation: "userStore" + queries: + - name: "GetByID" + sql: "SELECT id FROM users WHERE id = ?" + type: "select" + model: "User" + returns: "single" + params: + - name: "id" + type: "int64" +` +) + +func generateStoreTestCases() []generateStoreTestCase { + return []generateStoreTestCase{ + { + name: "single store - select single return", + yamlContent: yamlSingleStoreSelectSingle, + verify: verifySingleStoreSelectSingle, + }, + { + name: "single store - multiple return types", + yamlContent: yamlSingleStoreMultipleReturnTypes, + verify: verifySingleStoreMultipleReturnTypes, + }, + { + name: "multiple stores in one YAML", + yamlContent: yamlMultipleStores, + verify: verifyMultipleStores, + }, + { + name: "empty queries list", + yamlContent: yamlEmptyQueries, + verify: verifyEmptyQueries, + }, + { + name: "custom config path via -config flag", + yamlContent: yamlCustomConfigStore, + configFlag: "custom/myconfig.yaml", + verify: func(t *testing.T, dir string) { + t.Helper() + assert.FileExists(t, filepath.Join(dir, "stores", "custom", "interface.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "custom", "customStore.go")) + }, + }, + { + name: "config file not found", + configFlag: "nonexistent/config.yaml", + wantErr: true, + errContains: "error opening the config file", + }, + { + name: "no stores defined in YAML", + yamlContent: "version: \"1.0\"\nstores: []\n", + wantErr: true, + errContains: "no stores defined in configuration", + }, + { + name: "store with external model reference", + yamlContent: yamlExternalModelReference, + verify: verifyExternalModelReference, + }, + } +} + +func verifySingleStoreSelectSingle(t *testing.T, dir string) { + t.Helper() + + assert.DirExists(t, filepath.Join(dir, "stores", "user")) + assert.FileExists(t, filepath.Join(dir, "stores", "all.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "user", "interface.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "user", "userStore.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "user", "user.go")) + + ifaceContent, err := os.ReadFile(filepath.Join(dir, "stores", "user", "interface.go")) + require.NoError(t, err) + + ifaceStr := string(ifaceContent) + assert.Contains(t, ifaceStr, "type UserStore interface") + assert.Contains(t, ifaceStr, "GetUserByID(ctx *gofr.Context, id int64)") + assert.Contains(t, ifaceStr, "(User, error)") + + implContent, err := os.ReadFile(filepath.Join(dir, "stores", "user", "userStore.go")) + require.NoError(t, err) + + implStr := string(implContent) + assert.Contains(t, implStr, "func NewUserStore() UserStore") + assert.Contains(t, implStr, "TODO: Implement GetUserByID query") + + modelContent, err := os.ReadFile(filepath.Join(dir, "stores", "user", "user.go")) + require.NoError(t, err) + + modelStr := string(modelContent) + assert.Contains(t, modelStr, "type User struct") + assert.Contains(t, modelStr, "TableName()") + + allContent, err := os.ReadFile(filepath.Join(dir, "stores", "all.go")) + require.NoError(t, err) + + allStr := string(allContent) + assert.Contains(t, allStr, `"user": func() any`) + assert.Contains(t, allStr, "user.NewUserStore()") +} + +func verifySingleStoreMultipleReturnTypes(t *testing.T, dir string) { + t.Helper() + + ifaceContent, err := os.ReadFile(filepath.Join(dir, "stores", "product", "interface.go")) + require.NoError(t, err) + + ifaceStr := string(ifaceContent) + + assert.Contains(t, ifaceStr, "GetByID(ctx *gofr.Context, id int64)") + assert.Contains(t, ifaceStr, "(Product, error)") + assert.Contains(t, ifaceStr, "ListAll(ctx *gofr.Context)") + assert.Contains(t, ifaceStr, "([]Product, error)") + assert.Contains(t, ifaceStr, "CountActive(ctx *gofr.Context)") + assert.Contains(t, ifaceStr, "(int64, error)") + assert.Contains(t, ifaceStr, "CreateProduct(ctx *gofr.Context, name string)") + assert.Contains(t, ifaceStr, "(any, error)") +} + +func verifyMultipleStores(t *testing.T, dir string) { + t.Helper() + + assert.DirExists(t, filepath.Join(dir, "stores", "user")) + assert.DirExists(t, filepath.Join(dir, "stores", "order")) + assert.FileExists(t, filepath.Join(dir, "stores", "user", "interface.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "order", "interface.go")) + + allContent, err := os.ReadFile(filepath.Join(dir, "stores", "all.go")) + require.NoError(t, err) + + allStr := string(allContent) + assert.Contains(t, allStr, `"user": func() any`) + assert.Contains(t, allStr, "user.NewUserStore()") + assert.Contains(t, allStr, `"order": func() any`) + assert.Contains(t, allStr, "order.NewOrderStore()") +} + +func verifyEmptyQueries(t *testing.T, dir string) { + t.Helper() + + assert.FileExists(t, filepath.Join(dir, "stores", "empty", "interface.go")) + assert.FileExists(t, filepath.Join(dir, "stores", "empty", "emptyStore.go")) + + ifaceContent, err := os.ReadFile(filepath.Join(dir, "stores", "empty", "interface.go")) + require.NoError(t, err) + assert.Contains(t, string(ifaceContent), "type EmptyStore interface") +} + +func verifyExternalModelReference(t *testing.T, dir string) { + t.Helper() + + assert.FileExists(t, filepath.Join(dir, "stores", "user", "interface.go")) + assert.NoFileExists(t, filepath.Join(dir, "stores", "user", "user.go")) + + ifaceContent, err := os.ReadFile(filepath.Join(dir, "stores", "user", "interface.go")) + require.NoError(t, err) + assert.Contains(t, string(ifaceContent), "test-project/models") +} + +func runGenerateStoreTest(t *testing.T, tt *generateStoreTestCase) { + t.Helper() + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + err := os.WriteFile("go.mod", []byte("module test-project\n\ngo 1.22\n"), 0600) + require.NoError(t, err) + + configPath := filepath.Join("stores", "store.yaml") + args := []string{} + + if tt.configFlag != "" { + configPath = tt.configFlag + args = append(args, "-config="+tt.configFlag) + } + + if tt.yamlContent != "" { + dir := filepath.Dir(configPath) + if dir != "." { + err = os.MkdirAll(dir, 0755) + require.NoError(t, err) + } else { + err = os.MkdirAll("stores", 0755) + require.NoError(t, err) + } + + err = os.WriteFile(configPath, []byte(tt.yamlContent), 0600) + require.NoError(t, err) + } + + c := container.NewContainer(gofrConfig.NewEnvFile("", logging.NewMockLogger(logging.DEBUG))) + req := cmd.NewRequest(args) + ctx := &gofr.Context{Context: req.Context(), Request: req, Container: c} + + _, err = GenerateStore(ctx) + + if tt.wantErr { + require.Error(t, err) + + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + + return + } + + require.NoError(t, err) + + if tt.verify != nil { + tt.verify(t, tmpDir) + } +} + +func Test_GenerateStore(t *testing.T) { + for _, tt := range generateStoreTestCases() { + tc := tt + t.Run(tc.name, func(t *testing.T) { + runGenerateStoreTest(t, &tc) + }) + } +} diff --git a/store/templates.go b/store/templates.go index 6fa5bd2..f3b269b 100644 --- a/store/templates.go +++ b/store/templates.go @@ -190,7 +190,7 @@ models: # Multiple stores configuration stores: - - name: "{{ .PackageName }}" + - name: "{{ .StoreName }}" package: "{{ .PackageName }}" output_dir: "{{ .OutputDir }}" interface: "{{ .InterfaceName }}"