diff --git a/CLAUDE.md b/CLAUDE.md index a2b201f..714fd51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,10 +116,28 @@ The tool generates different Lambda structures based on trigger type: - **Custom**: User-defined custom event sources with configurable type path and optional idempotency ### Domain Generation -When creating domains with `draft new:domain`, the tool prompts for: +When creating domains with `draft new:domain`, the tool supports both interactive and non-interactive modes: + +**Interactive Mode** (default): - Domain path (automatically prefixed with `domains/` if not present) - Database type selection (Postgres or DynamoDB) - Database-specific configuration (table names, prefixes, etc.) +- For Postgres: Database selection from `.local-migrate-config.yml` + +**Non-Interactive Mode** (CLI flags): +- `--domain-path, -p`: Domain folder path +- `--db-type`: Database type (postgres or dynamo) +- `--table-name`: Database table name +- `--db-prefix`: ID prefix for Postgres (3 characters) +- `--db-name`: Database name for Postgres (loaded from config) + +**Database Configuration**: +For Postgres domains, available databases are dynamically loaded from `.local-migrate-config.yml`: +- Reads `migrations.databases` configuration +- Filters out test databases (`group: 'test'`) +- Converts snake_case names to PascalCase for provider functions + - Example: `user_preferences` → `ProvideUserPreferences` + - Example: `games_core` → `ProvideGamesCore` Domain structure varies by database type: - **Postgres**: Full CRUD with service layer, repository layer, builders, DAOs, providers, and domain models with search/filter capabilities @@ -189,7 +207,12 @@ Implementation in `internal/actions/mockery/`: - Uses `select` on semaphore acquisition to respect cancellation 5. Cleans up temporary files and displays execution summary (or cancellation summary if interrupted) -The `new:domain` action uses a simpler mockery integration: it adds packages to `.mockery.yml` and runs `mockery` directly without the config merging system. +The `new:domain` action integrates with the mockery action layer: +- Creates `.mockery.pkg.yml` files for service and repository packages +- Calls the mockery action directly (reuses `internal/actions/mockery`) +- Passes context for cancellation support +- Runs with jobsNum=2 for concurrent service and repository mock generation +- Benefits from progress reporting and error handling of the main mockery action ## Configuration Files @@ -230,6 +253,12 @@ Services use Pkl (configuration language) for app config: - Supports rooted patterns (`/dist`), directory-only patterns (`node_modules/`), negation patterns (`!important.txt`), and glob patterns (`**/*.yml`) - Optional `skipGitignore` parameter to disable `.gitignore` filtering - Used by mockery command to automatically skip vendor, node_modules, and other ignored directories + - `internal/pkg/migrateconfig/` provides database configuration utilities: + - Reads and parses `.local-migrate-config.yml` from project root + - Extracts `migrations.databases` configuration + - Filters databases by group (excludes `group: 'test'`) + - Provides `ToPascalCase()` for converting snake_case to PascalCase + - Formats database names for display in forms - `internal/dtos/` for data transfer objects passed between commands, forms, and actions - `internal/data/` for global state (flags, metadata, placeholder tags) - `cmd/commands/internal/common/` for command-level shared code @@ -272,7 +301,10 @@ New lambdas are added to existing files via string replacement: New domains have a different post-create flow: 1. `postgresModels()` (Postgres only) - Adds domain DAOs to provider test migrations using `NextDbModelTag` -2. `mockery()` - Adds domain service/repository packages to `.mockery.yml` and runs mockery to generate mocks +2. `mockery()` - Creates `.mockery.pkg.yml` files and runs mockery action to generate mocks + - Reuses `internal/actions/mockery` for concurrent execution + - Passes context for cancellation support + - Runs with jobsNum=2 for service and repository 3. `format()` - Runs goimports/gofmt on generated files ### Global State Usage diff --git a/README.md b/README.md index 3e77b9b..db67c38 100644 --- a/README.md +++ b/README.md @@ -150,14 +150,38 @@ For an HTTP Lambda: ### Creating a Domain Layer -Generate a complete domain layer following Domain-Driven Design principles. +Generate a complete domain layer following Domain-Driven Design principles with support for both interactive and non-interactive modes. #### Basic Usage +**Interactive Mode** (prompts for all values): ```bash draft new:domain ``` +**Non-Interactive Mode** (CI/CD friendly): +```bash +# Postgres domain +draft new:domain \ + --domain-path users \ + --db-type postgres \ + --table-name public.users \ + --db-prefix usr \ + --db-name general + +# DynamoDB domain +draft new:domain \ + --domain-path products \ + --db-type dynamo \ + --table-name ProductsTable +``` + +**Mixed Mode** (some flags, some prompts): +```bash +draft new:domain --domain-path orders --db-type postgres +# Prompts only for table-name, db-prefix, and db-name +``` + #### Database Support Draft supports multiple database backends: @@ -167,13 +191,46 @@ Draft supports multiple database backends: | **Postgres** | Full CRUD, search with filters/pagination, repository builders, DAOs, domain models | | **DynamoDB** | Simplified repository pattern, optimized for NoSQL | -#### Flags +#### Database Configuration -```bash -# Specify working directory -draft new:domain -w path/to/project +For Postgres domains, available databases are **dynamically loaded** from `.local-migrate-config.yml`: + +1. Reads `migrations.databases` from project root configuration +2. Filters out test databases (`group: 'test'`) +3. Presents remaining databases as options +4. Converts database names to PascalCase for provider functions + +**Example**: If `.local-migrate-config.yml` contains: +```yaml +migrations: + databases: + general: + folder: 'postgres' + general_test: + group: 'test' # This will be filtered out + user_preferences: + folder: 'user-preferences' + games_core: + folder: 'games-core' ``` +The command will: +- Show options: `General`, `User Preferences`, `Games Core` +- Generate provider calls: `ProvideGeneral`, `ProvideUserPreferences`, `ProvideGamesCore` + +#### Flags + +| Flag | Short | Description | Example | +|------|-------|-------------|---------| +| `--domain-path` | `-p` | Path to domain folder | `users`, `auth/sessions` | +| `--db-type` | | Database type | `postgres`, `dynamo` | +| `--table-name` | | Database table name | `public.users`, `ProductsTable` | +| `--db-prefix` | | ID prefix for Postgres (3 chars) | `usr`, `ord`, `prd` | +| `--db-name` | | Database name (from config) | `general`, `user_preferences` | +| `--working-dir` | `-w` | Working directory | `path/to/project` | + +**Note**: The `--db-name` flag accepts snake_case database names as they appear in `.local-migrate-config.yml`. The tool automatically converts them to PascalCase for provider function names. + #### What Gets Created (Postgres) ``` @@ -312,11 +369,14 @@ Failed packages: #### Integration with `new:domain` When creating domains with `draft new:domain`, mocks are automatically generated: -- Domain service and repository packages are added to `.mockery.yml` -- Mockery runs to generate mock implementations +- Creates `.mockery.pkg.yml` files for service and repository packages +- Calls the mockery action directly (reuses `internal/actions/mockery`) +- Runs concurrently with `jobsNum=2` for service and repository +- Provides progress reporting and error handling +- Supports cancellation with Ctrl+C - Mocks are placed in `domain/service/mocks` and `domain/repository/mocks` -**Note**: The `new:domain` action uses a simpler approach by directly updating `.mockery.yml` rather than the base/package config merging system. +The domain command benefits from the same mockery infrastructure used by the standalone `draft mockery` command, including concurrent execution, progress tracking, and proper error handling. --- @@ -474,6 +534,7 @@ draft/ │ ├── log/ # Logging utilities │ ├── format/ # Code formatting │ ├── constants/ # Shared constants +│ ├── migrateconfig/ # Database config utilities │ └── ... # Other utilities ├── Taskfile.yml # Task runner configuration ├── go.mod # Go module definition diff --git a/cmd/commands/newdomain/newdomain.go b/cmd/commands/newdomain/newdomain.go index ae71f8a..9dfe888 100644 --- a/cmd/commands/newdomain/newdomain.go +++ b/cmd/commands/newdomain/newdomain.go @@ -11,11 +11,81 @@ import ( "github.com/Drafteame/draft/internal/pkg/log" ) +var ( + domainPath string + dbType string + tableName string + dbPrefix string + dbName string +) + var newDomainCmd = &cobra.Command{ Use: "new:domain", - Short: "Create a new domain", - Long: "Create a new configurable domain, creates models, services, repositories and any other needed config to work", - Run: run, + Short: "Create a new domain with models, services, and repositories", + Long: `Create a new configurable domain with complete domain-driven design structure. + +This command scaffolds a domain layer including: +- Domain models and business logic (Postgres only) +- Service layer with CRUD operations +- Repository layer with database interactions +- Provider functions for dependency injection +- Mock configurations for testing + +The command supports both interactive mode (prompts for values) and non-interactive +mode (uses flags) for CI/CD automation. + +Database Types: + postgres - Creates full domain structure with Postgres support + Includes: search, filters, pagination, DAOs, and builders + dynamo - Creates simplified structure optimized for DynamoDB + Includes: basic CRUD operations + +Examples: + # Interactive mode - prompts for all values + draft new:domain + + # Non-interactive mode with Postgres + draft new:domain \ + --domain-path users \ + --db-type postgres \ + --table-name public.users \ + --db-prefix usr \ + --db-name general + + # Non-interactive mode with DynamoDB + draft new:domain \ + --domain-path products \ + --db-type dynamo \ + --table-name ProductsTable + + # Mixed mode - provide some flags, prompt for others + draft new:domain --domain-path orders --db-type postgres + + # With custom working directory + draft new:domain -w /path/to/project --domain-path inventory --db-type postgres + +Database Configuration: + For Postgres domains, the list of available databases is dynamically loaded from + the .local-migrate-config.yml file in the project root. The command will: + + 1. Read migrations.databases from .local-migrate-config.yml + 2. Filter out any databases with group: 'test' + 3. Present the remaining databases as options + 4. Convert database names to PascalCase for provider functions + Example: user_preferences -> ProvideUserPreferences + games_core -> ProvideGamesCore + + If the .local-migrate-config.yml file is not found, the command will fail with + an error. Ensure this file exists in your project root before running the command.`, + Run: run, +} + +func init() { + newDomainCmd.Flags().StringVarP(&domainPath, "domain-path", "p", "", "Path to the domain folder") + newDomainCmd.Flags().StringVar(&dbType, "db-type", "", "Database type (postgres or dynamo)") + newDomainCmd.Flags().StringVar(&tableName, "table-name", "", "Name of the database table") + newDomainCmd.Flags().StringVar(&dbPrefix, "db-prefix", "", "ID prefix for Postgres (3 characters)") + newDomainCmd.Flags().StringVar(&dbName, "db-name", "", "Database name for Postgres (loaded from .local-migrate-config.yml). Use the snake_case database name (e.g., 'general', 'user_preferences')") } func run(cmd *cobra.Command, _ []string) { @@ -23,13 +93,19 @@ func run(cmd *cobra.Command, _ []string) { data.LoadMeta() - input := dtos.DomainInput{} + input := dtos.DomainInput{ + DomainPath: domainPath, + DBType: dbType, + TableName: tableName, + DBPrefix: dbPrefix, + DBName: dbName, + } if err := forms.NewDomain(&input); err != nil { log.Exitf(1, "Failed to collect domain info: %v", err) } - if errExec := newdomain.New(input).Exec(); errExec != nil { + if errExec := newdomain.New(cmd.Context(), input).Exec(); errExec != nil { log.Exitf(1, "Failed to create domain: %v", errExec) } diff --git a/internal/actions/newdomain/exec.go b/internal/actions/newdomain/exec.go index 84fe4bf..9cd34b5 100644 --- a/internal/actions/newdomain/exec.go +++ b/internal/actions/newdomain/exec.go @@ -91,6 +91,7 @@ func (nd *NewDomain) createFiles(fileList []dtos.FileEntry) error { func (nd *NewDomain) createPostgresService() error { fileList := []dtos.FileEntry{ + {Path: nd.input.DomainPath + "/service/.mockery.pkg.yml", Data: nd.tmpl.Service.Postgres.DotMockeryPkgYml}, {Path: nd.input.DomainPath + "/service/create.go", Data: nd.tmpl.Service.Postgres.CreateGo}, {Path: nd.input.DomainPath + "/service/create_test.go", Data: nd.tmpl.Service.Postgres.CreateTestGo}, {Path: nd.input.DomainPath + "/service/delete.go", Data: nd.tmpl.Service.Postgres.DeleteGo}, @@ -114,6 +115,7 @@ func (nd *NewDomain) createPostgresService() error { func (nd *NewDomain) createDynamoService() error { fileList := []dtos.FileEntry{ + {Path: nd.input.DomainPath + "/service/.mockery.pkg.yml", Data: nd.tmpl.Service.Dynamo.DotMockeryPkgYml}, {Path: nd.input.DomainPath + "/service/interfaces.go", Data: nd.tmpl.Service.Dynamo.InterfacesGo}, {Path: nd.input.DomainPath + "/service/service.go", Data: nd.tmpl.Service.Dynamo.ServiceGo}, {Path: nd.input.DomainPath + "/service/provider.go", Data: nd.tmpl.Service.Dynamo.ProviderGo}, @@ -135,6 +137,7 @@ func (nd *NewDomain) createRepository() error { func (nd *NewDomain) createPostgresRepository() error { fileList := []dtos.FileEntry{ + {Path: nd.input.DomainPath + "/repository/.mockery.pkg.yml", Data: nd.tmpl.Repository.Postgres.DotMockeryPkgYml}, {Path: nd.input.DomainPath + "/repository/create.go", Data: nd.tmpl.Repository.Postgres.CreateGo}, {Path: nd.input.DomainPath + "/repository/create_test.go", Data: nd.tmpl.Repository.Postgres.CreateTestGo}, {Path: nd.input.DomainPath + "/repository/delete.go", Data: nd.tmpl.Repository.Postgres.DeleteGo}, @@ -165,6 +168,7 @@ func (nd *NewDomain) createPostgresRepository() error { func (nd *NewDomain) createDynamoRepository() error { fileList := []dtos.FileEntry{ + {Path: nd.input.DomainPath + "/repository/.mockery.pkg.yml", Data: nd.tmpl.Repository.Dynamo.DotMockeryPkgYml}, {Path: nd.input.DomainPath + "/repository/interfaces.go", Data: nd.tmpl.Repository.Dynamo.InterfacesGo}, {Path: nd.input.DomainPath + "/repository/repository.go", Data: nd.tmpl.Repository.Dynamo.RepositoryGo}, {Path: nd.input.DomainPath + "/repository/provider.go", Data: nd.tmpl.Repository.Dynamo.ProviderGo}, diff --git a/internal/actions/newdomain/mockery.go b/internal/actions/newdomain/mockery.go index 6066646..6382253 100644 --- a/internal/actions/newdomain/mockery.go +++ b/internal/actions/newdomain/mockery.go @@ -2,29 +2,20 @@ package newdomain import ( "errors" - "fmt" "github.com/charmbracelet/huh/spinner" - "gopkg.in/yaml.v3" - "github.com/Drafteame/draft/internal/pkg/exec" - "github.com/Drafteame/draft/internal/pkg/files" + "github.com/Drafteame/draft/internal/actions/mockery" ) func (nd *NewDomain) mockery() error { var err error - spin := spinner.New().Title("Adding mockery configs") + spin := spinner.New().Title("Generating mocks") action := func() { - spin.Update("Creating mockery package") - err = nd.addMockeryPackages() - if err != nil { - return - } - - spin.Update("Running mockery to create files") - err = nd.createMockeryFiles() + spin.Update("Running mockery to create mocks") + err = nd.runMockery() if err != nil { return } @@ -35,52 +26,18 @@ func (nd *NewDomain) mockery() error { return errors.Join(spinErr, err) } -func (nd *NewDomain) addMockeryPackages() error { - paths := map[string]string{ - nd.input.PackageName + "/" + nd.input.DomainPath + "/service": nd.input.DomainPath + "/service/mocks", - nd.input.PackageName + "/" + nd.input.DomainPath + "/repository": nd.input.DomainPath + "/repository/mocks", - } - - mockeryConfig, err := files.Read(".mockery.yml") - if err != nil { - return err - } +func (nd *NewDomain) runMockery() error { + // Build the config file paths for both service and repository + serviceMockeryPath := nd.input.DomainPath + "/service/.mockery.pkg.yml" + repositoryMockeryPath := nd.input.DomainPath + "/repository/.mockery.pkg.yml" - config := map[string]any{} + configFiles := []string{serviceMockeryPath, repositoryMockeryPath} - if err := yaml.Unmarshal(mockeryConfig, &config); err != nil { - return err - } - - packages, ok := config["packages"].(map[string]any) - if !ok { - return fmt.Errorf("packages key not found or invalid in .mockery.yml") - } - - for pkgPath, mocksDir := range paths { - packages[pkgPath] = map[string]any{ - "config": map[string]any{ - "all": true, - "dir": mocksDir, - "filename": "mock_{{.InterfaceName}}.go", - "inpackage": false, - }, - } - } - - newConfig, err := yaml.Marshal(config) - if err != nil { - return err - } - - return files.Create(".mockery.yml", newConfig) -} - -func (nd *NewDomain) createMockeryFiles() error { - _, err := exec.Command("mockery") - if err != nil { - return fmt.Errorf("command 'mockery' failed: %w", err) - } + // Run mockery action with config files + // Using jobsNum=2 (one for service, one for repository) + // dry=false (actually execute mockery) + // gitMod=false (not using git diff mode) + m := mockery.New(nd.ctx, configFiles, 2, false, false) - return nil + return m.Exec() } diff --git a/internal/actions/newdomain/newdomain.go b/internal/actions/newdomain/newdomain.go index fc5451b..8a94caf 100644 --- a/internal/actions/newdomain/newdomain.go +++ b/internal/actions/newdomain/newdomain.go @@ -1,17 +1,21 @@ package newdomain import ( + "context" + "github.com/Drafteame/draft/internal/dtos" "github.com/Drafteame/draft/internal/templates" ) type NewDomain struct { + ctx context.Context input dtos.DomainInput tmpl templates.Domains } -func New(input dtos.DomainInput) *NewDomain { +func New(ctx context.Context, input dtos.DomainInput) *NewDomain { return &NewDomain{ + ctx: ctx, input: input, } } diff --git a/internal/forms/newdomain/base_form.go b/internal/forms/newdomain/base_form.go index 7c374cf..d3209c6 100644 --- a/internal/forms/newdomain/base_form.go +++ b/internal/forms/newdomain/base_form.go @@ -15,20 +15,28 @@ import ( ) func baseForm(input *dtos.DomainInput) error { - err := inputs.Text("Domain Path:", - inputs.WithValue(&input.DomainPath), - inputs.WithDescription[string]("Enter the path to the domain folder."), - inputs.WithValidation(func(val string) error { - if val == "" { - return errors.New("domain path cannot be empty") - } - - return nil - }), - ) - - if err != nil { - return err + // Prompt for domain path only if not provided via flag + if input.DomainPath == "" { + err := inputs.Text("Domain Path:", + inputs.WithValue(&input.DomainPath), + inputs.WithDescription[string]("Enter the path to the domain folder."), + inputs.WithValidation(func(val string) error { + if val == "" { + return errors.New("domain path cannot be empty") + } + + return nil + }), + ) + + if err != nil { + return err + } + } + + // Validate domain path if provided via flag + if input.DomainPath == "" { + return errors.New("domain path cannot be empty") } input.DomainPath = strings.ToLower(input.DomainPath) @@ -43,16 +51,28 @@ func baseForm(input *dtos.DomainInput) error { input.DomainPath = "domains/" + input.DomainPath } - err = inputs.Select[string]("Select DB Type:", - inputs.WithDescription[string]("Select the type of database you want to use"), - inputs.WithValue(&input.DBType), - inputs.WithOptions(map[string]string{ - "Postgres": data.DBTypePostgres, - "DynamoDB": data.DBTypeDynamo, - }), - ) + // Prompt for DB type only if not provided via flag + if input.DBType == "" { + err := inputs.Select[string]("Select DB Type:", + inputs.WithDescription[string]("Select the type of database you want to use"), + inputs.WithValue(&input.DBType), + inputs.WithOptions(map[string]string{ + "Postgres": data.DBTypePostgres, + "DynamoDB": data.DBTypeDynamo, + }), + ) + + if err != nil { + return err + } + } + + // Validate DB type if provided via flag + if input.DBType != data.DBTypePostgres && input.DBType != data.DBTypeDynamo { + return errors.New("invalid db-type: must be 'postgres' or 'dynamo'") + } - return err + return nil } func normalizeDomainName(name string) string { diff --git a/internal/forms/newdomain/dynamo_form.go b/internal/forms/newdomain/dynamo_form.go index 727af1f..f984eb1 100644 --- a/internal/forms/newdomain/dynamo_form.go +++ b/internal/forms/newdomain/dynamo_form.go @@ -8,20 +8,28 @@ import ( ) func dynamoForm(input *dtos.DomainInput) error { - err := inputs.Text("Table Name:", - inputs.WithDescription[string]("Enter the name of the DynamoDB table to use on this domain."), - inputs.WithValue(&input.TableName), - inputs.WithValidation(func(s string) error { - if s == "" { - return errors.New("table name cannot be empty") - } + // Prompt for table name only if not provided via flag + if input.TableName == "" { + err := inputs.Text("Table Name:", + inputs.WithDescription[string]("Enter the name of the DynamoDB table to use on this domain."), + inputs.WithValue(&input.TableName), + inputs.WithValidation(func(s string) error { + if s == "" { + return errors.New("table name cannot be empty") + } - return nil - }), - ) + return nil + }), + ) - if err != nil { - return err + if err != nil { + return err + } + } + + // Validate table name if provided via flag + if input.TableName == "" { + return errors.New("table name cannot be empty") } return nil diff --git a/internal/forms/newdomain/postgres_form.go b/internal/forms/newdomain/postgres_form.go index 455fcad..41daa53 100644 --- a/internal/forms/newdomain/postgres_form.go +++ b/internal/forms/newdomain/postgres_form.go @@ -2,67 +2,108 @@ package newdomain import ( "errors" + "fmt" + "github.com/Drafteame/draft/internal/data" "github.com/Drafteame/draft/internal/dtos" "github.com/Drafteame/draft/internal/pkg/inputs" + "github.com/Drafteame/draft/internal/pkg/migrateconfig" ) func postgresForm(input *dtos.DomainInput) error { - err := inputs.Text("Table Name:", - inputs.WithDescription[string]("Enter the name of the table to use on this domain (you can specify schema to by using 'schema.table' notation)."), - inputs.WithValue(&input.TableName), - inputs.WithValidation(func(s string) error { - if s == "" { - return errors.New("table name cannot be empty") - } + // Prompt for table name only if not provided via flag + if input.TableName == "" { + err := inputs.Text("Table Name:", + inputs.WithDescription[string]("Enter the name of the table to use on this domain (you can specify schema to by using 'schema.table' notation)."), + inputs.WithValue(&input.TableName), + inputs.WithValidation(func(s string) error { + if s == "" { + return errors.New("table name cannot be empty") + } - return nil - }), - ) + return nil + }), + ) - if err != nil { - return err + if err != nil { + return err + } } - err = inputs.Text("Set ID Prefix:", - inputs.WithDescription[string]("Enter the prefix to use for values on the ID field (length should be 3 chars)"), - inputs.WithValue(&input.DBPrefix), - inputs.WithValidation(func(s string) error { - if len(s) != 3 { - return errors.New("prefix should be 3 characters long") - } + // Validate table name if provided via flag + if input.TableName == "" { + return errors.New("table name cannot be empty") + } - return nil - }), - ) + // Prompt for DB prefix only if not provided via flag + if input.DBPrefix == "" { + err := inputs.Text("Set ID Prefix:", + inputs.WithDescription[string]("Enter the prefix to use for values on the ID field (length should be 3 chars)"), + inputs.WithValue(&input.DBPrefix), + inputs.WithValidation(func(s string) error { + if len(s) != 3 { + return errors.New("prefix should be 3 characters long") + } - if err != nil { - return err + return nil + }), + ) + + if err != nil { + return err + } + } + + // Validate DB prefix if provided via flag + if len(input.DBPrefix) != 3 { + return errors.New("db-prefix should be 3 characters long") } - err = inputs.Select[string]("Select an available database to connect:", - inputs.WithDescription[string]("Select the database that should be connected on the domain"), - inputs.WithValue(&input.DBName), - inputs.WithOptions(map[string]string{ - "Audiences": "Audiences", - "Data Products": "DataProducts", - "Fraud": "fraud", - "Games Core": "GamesCore", - "General": "General", - "Kyc": "Kyc", - "Notification Engine": "NotificationEngine", - "Scores": "Scores", - "Stats": "Stats", - "Turbo": "Turbo", - "User Preferences": "UserPreferences", - }), - ) + // Load available databases from .local-migrate-config.yml + workingDir := data.Flags.WorkingDir + if workingDir == "" { + workingDir = "." + } + databases, err := migrateconfig.GetAvailableDatabases(workingDir) if err != nil { - return err + return fmt.Errorf("failed to load available databases: %w", err) + } + + // Prompt for DB name only if not provided via flag + if input.DBName == "" { + err := inputs.Select[string]("Select an available database to connect:", + inputs.WithDescription[string]("Select the database that should be connected on the domain"), + inputs.WithValue(&input.DBName), + inputs.WithOptions(databases), + ) + + if err != nil { + return err + } + } + + // Validate DB name if provided via flag + if input.DBName != "" { + validDBName := false + for _, dbName := range databases { + if dbName == input.DBName { + validDBName = true + break + } + } + + if !validDBName { + availableDBs := make([]string, 0, len(databases)) + for _, dbName := range databases { + availableDBs = append(availableDBs, dbName) + } + return fmt.Errorf("invalid db-name '%s': must be one of %v", input.DBName, availableDBs) + } } - input.DBProviderFuncName = input.DBName + // Convert database name to PascalCase for provider function name + input.DBProviderFuncName = migrateconfig.ToPascalCase(input.DBName) return nil } diff --git a/internal/pkg/migrateconfig/migrateconfig.go b/internal/pkg/migrateconfig/migrateconfig.go new file mode 100644 index 0000000..0e57add --- /dev/null +++ b/internal/pkg/migrateconfig/migrateconfig.go @@ -0,0 +1,108 @@ +package migrateconfig + +import ( + "fmt" + "os" + "strings" + + "github.com/samber/lo" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/yaml.v3" + + "github.com/Drafteame/draft/internal/pkg/files" +) + +const ( + migrateConfigFile = ".local-migrate-config.yml" +) + +type MigrateConfig struct { + Migrations Migrations `yaml:"migrations"` +} + +type Migrations struct { + BasePath string `yaml:"base_path"` + Databases map[string]DBConfig `yaml:"databases"` +} + +type DBConfig struct { + Group string `yaml:"group"` + Folder string `yaml:"folder"` + Connection map[string]any `yaml:"connection"` +} + +// GetAvailableDatabases reads the .local-migrate-config.yml file and returns +// a list of available databases that are not in the test group +func GetAvailableDatabases(workingDir string) (map[string]string, error) { + configPath := fmt.Sprintf("%s/%s", workingDir, migrateConfigFile) + + if !files.Exists(configPath) { + return nil, fmt.Errorf("migrate config file not found: %s", configPath) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read migrate config: %w", err) + } + + var config MigrateConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse migrate config: %w", err) + } + + databases := make(map[string]string) + + for dbName, dbConfig := range config.Migrations.Databases { + // Skip test databases + if dbConfig.Group == "test" { + continue + } + + // Use the database name as display label + displayName := formatDisplayName(dbName) + databases[displayName] = dbName + } + + if len(databases) == 0 { + return nil, fmt.Errorf("no non-test databases found in migrate config") + } + + return databases, nil +} + +// ToPascalCase converts a snake_case string to PascalCase +// Examples: +// - general -> General +// - user_preferences -> UserPreferences +// - games_core -> GamesCore +func ToPascalCase(s string) string { + if s == "" { + return "" + } + + caser := cases.Title(language.English) + parts := strings.Split(s, "_") + + pascalParts := lo.Map(parts, func(part string, _ int) string { + return caser.String(part) + }) + + return strings.Join(pascalParts, "") +} + +// formatDisplayName formats a database name for display in the selection list +// Examples: +// - general -> General +// - user_preferences -> User Preferences +// - games_core -> Games Core +func formatDisplayName(dbName string) string { + caser := cases.Title(language.English) + parts := strings.Split(dbName, "_") + + titleParts := lo.Map(parts, func(part string, _ int) string { + return caser.String(part) + }) + + return strings.Join(titleParts, " ") +} diff --git a/internal/templates/domains_repository.go b/internal/templates/domains_repository.go index 3761292..dae7151 100644 --- a/internal/templates/domains_repository.go +++ b/internal/templates/domains_repository.go @@ -43,6 +43,7 @@ func loadRepositoryDynamo(v *Repository, data any) error { } type RepositoryPostgres struct { + DotMockeryPkgYml []byte CreateGo []byte CreateTestGo []byte DeleteGo []byte @@ -65,6 +66,7 @@ type RepositoryPostgres struct { func loadDomainsRepositoryPostgres(v *RepositoryPostgres, data any) error { loaders := []func(*RepositoryPostgres, any) error{ + loadRepositoryPostgresDotMockeryPkgYml, loadRepositoryPostgresCreateGo, loadRepositoryPostgresCreateGoTest, loadRepositoryPostgresDeleteGo, @@ -94,6 +96,20 @@ func loadDomainsRepositoryPostgres(v *RepositoryPostgres, data any) error { return nil } +func loadRepositoryPostgresDotMockeryPkgYml(v *RepositoryPostgres, data any) error { + name := "domains/repository/postgres/.mockery.pkg.yml" + path := "tmpl/domain/repository/postgres/.mockery.pkg.yml.tmpl" + + content, err := loadTemplate(name, path, data, domain) + if err != nil { + return err + } + + v.DotMockeryPkgYml = content + + return nil +} + func loadRepositoryPostgresCreateGo(v *RepositoryPostgres, data any) error { name := "domains/repository/postgres/create.go" path := "tmpl/domain/repository/postgres/create.go.tmpl" @@ -471,13 +487,15 @@ func loadRepositoryPostgresDaosUpdateGo(v *RepositoryPostgresDaos, data any) err } type RepositoryDynamo struct { - InterfacesGo []byte - RepositoryGo []byte - ProviderGo []byte + DotMockeryPkgYml []byte + InterfacesGo []byte + RepositoryGo []byte + ProviderGo []byte } func loadDomainsRepositoryDynamo(v *RepositoryDynamo, data any) error { loaders := []func(*RepositoryDynamo, any) error{ + loadRepositoryDynamoDotMockeryPkgYml, loadRepositoryDynamoInterfacesGo, loadRepositoryDynamoRepositoryGo, loadRepositoryDynamoProviderGo, @@ -492,6 +510,20 @@ func loadDomainsRepositoryDynamo(v *RepositoryDynamo, data any) error { return nil } +func loadRepositoryDynamoDotMockeryPkgYml(v *RepositoryDynamo, data any) error { + name := "domains/repository/dynamo/.mockery.pkg.yml" + path := "tmpl/domain/repository/dynamo/.mockery.pkg.yml.tmpl" + + content, err := loadTemplate(name, path, data, domain) + if err != nil { + return err + } + + v.DotMockeryPkgYml = content + + return nil +} + func loadRepositoryDynamoInterfacesGo(v *RepositoryDynamo, data any) error { name := "domains/repository/dynamo/interfaces.go" path := "tmpl/domain/repository/dynamo/interfaces.go.tmpl" diff --git a/internal/templates/domains_service.go b/internal/templates/domains_service.go index 7aba576..aa610af 100644 --- a/internal/templates/domains_service.go +++ b/internal/templates/domains_service.go @@ -43,26 +43,28 @@ func loadServiceDynamo(v *Service, data any) error { } type ServicePostgres struct { - CreateGo []byte - CreateTestGo []byte - DeleteGo []byte - DeleteTestGo []byte - GetGo []byte - GetTestGo []byte - InterfacesGo []byte - SearchGo []byte - SearchTestGo []byte - SearchOneGo []byte - SearchOneTestGo []byte - ServiceGo []byte - ServiceTestGo []byte - UpdateGo []byte - UpdateTestGo []byte - ProvideGo []byte + DotMockeryPkgYml []byte + CreateGo []byte + CreateTestGo []byte + DeleteGo []byte + DeleteTestGo []byte + GetGo []byte + GetTestGo []byte + InterfacesGo []byte + SearchGo []byte + SearchTestGo []byte + SearchOneGo []byte + SearchOneTestGo []byte + ServiceGo []byte + ServiceTestGo []byte + UpdateGo []byte + UpdateTestGo []byte + ProvideGo []byte } func loadDomainsServicePostgres(v *ServicePostgres, data any) error { loaders := []func(*ServicePostgres, any) error{ + loadServicePostgresDotMockeryPkgYml, loadServicePostgresCreateGo, loadServicePostgresCreateTestGo, loadServicePostgresDeleteGo, @@ -90,6 +92,20 @@ func loadDomainsServicePostgres(v *ServicePostgres, data any) error { return nil } +func loadServicePostgresDotMockeryPkgYml(v *ServicePostgres, data any) error { + name := "domains/service/postgres/.mockery.pkg.yml" + path := "tmpl/domain/service/postgres/.mockery.pkg.yml.tmpl" + + content, err := loadTemplate(name, path, data, domain) + if err != nil { + return err + } + + v.DotMockeryPkgYml = content + + return nil +} + func loadServicePostgresCreateGo(v *ServicePostgres, data any) error { name := "domains/service/postgres/create.go" path := "tmpl/domain/service/postgres/create.go.tmpl" @@ -315,13 +331,15 @@ func loadServicePostgresProvideGo(v *ServicePostgres, data any) error { } type ServiceDynamo struct { - InterfacesGo []byte - ServiceGo []byte - ProviderGo []byte + DotMockeryPkgYml []byte + InterfacesGo []byte + ServiceGo []byte + ProviderGo []byte } func loadDomainsServiceDynamo(v *ServiceDynamo, data any) error { loaders := []func(*ServiceDynamo, any) error{ + loadServiceDynamoDotMockeryPkgYml, loadServiceDynamoInterfacesGo, loadServiceDynamoServiceGo, loadServiceDynamoProviderGo, @@ -336,6 +354,20 @@ func loadDomainsServiceDynamo(v *ServiceDynamo, data any) error { return nil } +func loadServiceDynamoDotMockeryPkgYml(v *ServiceDynamo, data any) error { + name := "domains/service/dynamo/.mockery.pkg.yml" + path := "tmpl/domain/service/dynamo/.mockery.pkg.yml.tmpl" + + content, err := loadTemplate(name, path, data, domain) + if err != nil { + return err + } + + v.DotMockeryPkgYml = content + + return nil +} + func loadServiceDynamoInterfacesGo(v *ServiceDynamo, data any) error { name := "domains/service/dynamo/interfaces.go" path := "tmpl/domain/service/dynamo/interfaces.go.tmpl" diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 97cbebe..8f3ac5a 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -11,6 +11,10 @@ var ( sls embed.FS //go:embed tmpl/domain + //go:embed tmpl/domain/service/postgres/.mockery.pkg.yml.tmpl + //go:embed tmpl/domain/service/dynamo/.mockery.pkg.yml.tmpl + //go:embed tmpl/domain/repository/postgres/.mockery.pkg.yml.tmpl + //go:embed tmpl/domain/repository/dynamo/.mockery.pkg.yml.tmpl domain embed.FS ) diff --git a/internal/templates/tmpl/domain/repository/dynamo/.mockery.pkg.yml.tmpl b/internal/templates/tmpl/domain/repository/dynamo/.mockery.pkg.yml.tmpl new file mode 100644 index 0000000..3fcd72e --- /dev/null +++ b/internal/templates/tmpl/domain/repository/dynamo/.mockery.pkg.yml.tmpl @@ -0,0 +1,4 @@ +packages: + {{.PackageName}}/{{ .DomainPath }}/repository: + interfaces: + Dynamo: {} \ No newline at end of file diff --git a/internal/templates/tmpl/domain/repository/postgres/.mockery.pkg.yml.tmpl b/internal/templates/tmpl/domain/repository/postgres/.mockery.pkg.yml.tmpl new file mode 100644 index 0000000..1cd23b6 --- /dev/null +++ b/internal/templates/tmpl/domain/repository/postgres/.mockery.pkg.yml.tmpl @@ -0,0 +1,5 @@ +packages: + {{ .PackageName }}/{{ .DomainPath }}/repository: + interfaces: + GeneratorID: {} + Clock: {} \ No newline at end of file diff --git a/internal/templates/tmpl/domain/repository/postgres/provide.go.tmpl b/internal/templates/tmpl/domain/repository/postgres/provide.go.tmpl index 26bf263..11b530c 100644 --- a/internal/templates/tmpl/domain/repository/postgres/provide.go.tmpl +++ b/internal/templates/tmpl/domain/repository/postgres/provide.go.tmpl @@ -8,6 +8,7 @@ import ( prvdatetime "{{.PackageName}}/pkg/providers/datetime" prvgenerator "{{.PackageName}}/pkg/providers/generators/nanoid/tableid" prvpostgres "{{.PackageName}}/pkg/providers/postgres" + "{{.PackageName}}/projects/framev2/pkg/drivers/gorm" "{{.PackageName}}/projects/framev2/pkg/stage" "{{.PackageName}}/projects/framev2/pkg/tracer" ) @@ -71,16 +72,12 @@ func createInstance(ctx context.Context, opts ...providers.Option) (any, error) newCtx, span := tracer.BeginSubSegment(ctx, "domains.{{ .DomainNameLower }}.repository.createInstance") defer span.Close(nil) - postgres{{.DomainNamePascal}}, err := prvpostgres.Provide{{.DBProviderFuncName}}(opts...) - if err != nil { - _ = span.AddError(err) - return nil, err - } + driver := providers.MustAs[gorm.Driver](newCtx, prvpostgres.Provide{{.DBProviderFuncName}}, opts...) clock := providers.MustAs[Clock](newCtx, prvdatetime.ProvideClock, opts...) generatorID := providers.MustAs[GeneratorID](newCtx, prvgenerator.Provide{{.DomainNamePascal}}, opts...) - return New(postgres{{.DomainNamePascal}}, clock, generatorID), nil + return New(driver, clock, generatorID), nil } diff --git a/internal/templates/tmpl/domain/service/dynamo/.mockery.pkg.yml.tmpl b/internal/templates/tmpl/domain/service/dynamo/.mockery.pkg.yml.tmpl new file mode 100644 index 0000000..91e891d --- /dev/null +++ b/internal/templates/tmpl/domain/service/dynamo/.mockery.pkg.yml.tmpl @@ -0,0 +1,4 @@ +packages: + {{.PackageName}}/{{ .DomainPath }}/service: + interfaces: + Repository: {} \ No newline at end of file diff --git a/internal/templates/tmpl/domain/service/postgres/.mockery.pkg.yml.tmpl b/internal/templates/tmpl/domain/service/postgres/.mockery.pkg.yml.tmpl new file mode 100644 index 0000000..ac7ff5b --- /dev/null +++ b/internal/templates/tmpl/domain/service/postgres/.mockery.pkg.yml.tmpl @@ -0,0 +1,4 @@ +packages: + {{ .PackageName }}/{{ .DomainPath }}/service: + interfaces: + Repository: {} \ No newline at end of file