From 2c1c5434970effac1d26151e51f856be4119373f Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Sat, 24 Jan 2026 16:17:22 -0300 Subject: [PATCH] refactor: implement native database/sql repositories Remove github.com/allisson/sqlutil library and replace with native Go database/sql implementations. This change creates separate repository implementations for MySQL and PostgreSQL, providing better control, clarity, and eliminating external dependencies. Breaking Changes: - UserRepository split into MySQLUserRepository and PostgreSQLUserRepository - OutboxEventRepository split into MySQLOutboxEventRepository and PostgreSQLOutboxEventRepository - Repositories now return interfaces from usecase package instead of concrete types - DI container updated to instantiate correct repository based on DB_DRIVER config Changes: - internal/database/txmanager.go: * Remove sqlutil.Querier import * Define custom Querier interface matching database/sql * Update GetTx() to return new Querier interface - internal/user/repository/: * Delete user_repository.go (unified implementation) * Add mysql_user_repository.go with MySQL-specific implementation - Uses BINARY(16) for UUID storage with marshal/unmarshal - Uses ? placeholders for parameters - MySQL-specific unique constraint error detection * Add postgresql_user_repository.go with PostgreSQL-specific implementation - Uses native UUID type - Uses $1, $2, $3... numbered placeholders - PostgreSQL-specific unique constraint error detection * Add mysql_user_repository_test.go with comprehensive tests * Add postgresql_user_repository_test.go with comprehensive tests * Delete user_repository_test.go (old tests) - internal/outbox/repository/: * Delete outbox_repository.go (unified implementation) * Add mysql_outbox_repository.go with MySQL-specific implementation * Add postgresql_outbox_repository.go with PostgreSQL-specific implementation * Add mysql_outbox_repository_test.go with comprehensive tests * Add postgresql_outbox_repository_test.go with comprehensive tests * Delete outbox_repository_test.go (old tests) - internal/app/di.go: * Update Container struct to use interface types for repositories * Update UserRepository() to return userUsecase.UserRepository interface * Update OutboxRepository() to return userUsecase.OutboxEventRepository interface * Update initUserRepository() to switch on DBDriver and instantiate correct implementation * Update initOutboxRepository() to switch on DBDriver and instantiate correct implementation - go.mod: * Remove github.com/allisson/sqlutil v1.10.0 * Remove indirect dependencies: sqlquery, scany, sqlbuilder, xstrings - Documentation: * README.md: Add "Database Repository Pattern" section explaining the architecture * README.md: Update all code examples to show database/sql usage * README.md: Update project structure to show separate repository files * README.md: Update dependencies list removing sqlutil * internal/app/README.md: Update dependency graph showing interface pattern * internal/app/README.md: Update initialization examples with driver switch logic Implementation Details: MySQL Repositories: - Use BINARY(16) for UUID storage requiring ID.MarshalBinary() for writes and ID.UnmarshalBinary() for reads - Use ? placeholders for all query parameters - Detect unique violations by checking for "1062" or "duplicate entry" in error messages - Use FOR UPDATE SKIP LOCKED syntax for outbox event locking PostgreSQL Repositories: - Use native UUID type, passing uuid.UUID directly without conversion - Use numbered placeholders ($1, $2, $3...) - Detect unique violations by checking for "duplicate key" or "unique constraint" - Use FOR UPDATE SKIP LOCKED syntax for outbox event locking Benefits: - Zero external dependencies for database operations (standard library only) - Database-specific optimizations (native UUID support in PostgreSQL) - Explicit SQL queries improve code clarity and maintainability - Easier to debug and optimize database-specific issues - Better type safety without abstraction layers - Reduced dependency tree and faster builds Testing: - All existing tests updated and passing - New tests cover MySQL and PostgreSQL implementations separately - Tests use sqlmock for database mocking - Coverage maintained at same level as before Lint: - Fixed errcheck warnings by adding //nolint:errcheck to defer rows.Close() - All golangci-lint checks passing (0 issues) --- .golangci.yml | 9 + Makefile | 2 +- README.md | 135 ++++++++++++-- cmd/app/main.go | 11 +- go.mod | 6 - go.sum | 38 ---- internal/app/README.md | 53 ++++-- internal/app/di.go | 34 +++- internal/config/config.go | 7 +- internal/config/config_test.go | 6 +- internal/database/txmanager.go | 12 +- internal/http/http_test.go | 14 +- internal/http/middleware.go | 12 +- internal/outbox/domain/outbox_event.go | 16 +- .../repository/mysql_outbox_repository.go | 108 +++++++++++ .../mysql_outbox_repository_test.go | 174 ++++++++++++++++++ .../outbox/repository/outbox_repository.go | 57 ------ .../postgresql_outbox_repository.go | 90 +++++++++ ...o => postgresql_outbox_repository_test.go} | 103 ++++------- internal/user/domain/user.go | 9 +- .../user/repository/mysql_user_repository.go | 122 ++++++++++++ .../repository/mysql_user_repository_test.go | 161 ++++++++++++++++ .../repository/postgresql_user_repository.go | 98 ++++++++++ ....go => postgresql_user_repository_test.go} | 55 ++---- internal/user/repository/user_repository.go | 90 --------- internal/user/usecase/user_usecase.go | 5 +- internal/user/usecase/user_usecase_test.go | 10 +- internal/validation/rules.go | 20 +- internal/worker/event_worker_test.go | 8 +- 29 files changed, 1101 insertions(+), 364 deletions(-) create mode 100644 internal/outbox/repository/mysql_outbox_repository.go create mode 100644 internal/outbox/repository/mysql_outbox_repository_test.go delete mode 100644 internal/outbox/repository/outbox_repository.go create mode 100644 internal/outbox/repository/postgresql_outbox_repository.go rename internal/outbox/repository/{outbox_repository_test.go => postgresql_outbox_repository_test.go} (58%) create mode 100644 internal/user/repository/mysql_user_repository.go create mode 100644 internal/user/repository/mysql_user_repository_test.go create mode 100644 internal/user/repository/postgresql_user_repository.go rename internal/user/repository/{user_repository_test.go => postgresql_user_repository_test.go} (79%) delete mode 100644 internal/user/repository/user_repository.go diff --git a/.golangci.yml b/.golangci.yml index dfaa63d..6ace1b6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,7 +4,16 @@ linters: enable: - gosec formatters: + enable: + - goimports + - golines settings: goimports: local-prefixes: - github.com/allisson/go-project-template + golines: + max-len: 110 + tab-len: 4 + shorten-comments: false + reformat-tags: true + chain-split-dots: true diff --git a/Makefile b/Makefile index 7023b50..8c8b58d 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ test-coverage: test ## Run tests and show coverage in browser lint: ## Run linter @echo "Running linter..." - @golangci-lint run -v + @golangci-lint run -v --fix clean: ## Remove build artifacts @echo "Cleaning..." diff --git a/README.md b/README.md index caf6cea..4bb0596 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A production-ready Go project template following Clean Architecture and Domain-D - **Clean Architecture** - Separation of concerns with domain, repository, use case, and presentation layers - **Standardized Error Handling** - Domain errors with proper HTTP status code mapping - **Dependency Injection Container** - Centralized component wiring with lazy initialization and clean resource management -- **Multiple Database Support** - PostgreSQL and MySQL via unified repository layer +- **Multiple Database Support** - PostgreSQL and MySQL with dedicated repository implementations using `database/sql` - **Database Migrations** - Separate migrations for PostgreSQL and MySQL using golang-migrate - **UUIDv7 Primary Keys** - Time-ordered, sortable UUIDs for globally unique identifiers - **Transaction Management** - TxManager interface for handling database transactions @@ -54,7 +54,8 @@ go-project-template/ │ │ ├── domain/ # Outbox entities │ │ │ └── outbox_event.go │ │ └── repository/ # Outbox data access -│ │ └── outbox_repository.go +│ │ ├── mysql_outbox_repository.go +│ │ └── postgresql_outbox_repository.go │ ├── user/ # User domain module │ │ ├── domain/ # User entities and domain errors │ │ │ └── user.go @@ -65,7 +66,8 @@ go-project-template/ │ │ │ │ └── mapper.go │ │ │ └── user_handler.go │ │ ├── repository/ # User data access -│ │ │ └── user_repository.go +│ │ │ ├── mysql_user_repository.go +│ │ │ └── postgresql_user_repository.go │ │ └── usecase/ # User business logic │ │ └── user_usecase.go │ ├── validation/ # Custom validation rules @@ -402,8 +404,15 @@ var ( **1. Repository Layer** - Transforms infrastructure errors to domain errors: ```go -func (r *UserRepository) GetByID(ctx context.Context, id int64) (*domain.User, error) { - if err := sqlutil.Get(ctx, querier, "users", opts, &user); err != nil { +func (r *PostgreSQLUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + querier := database.GetTx(ctx, r.db) + query := `SELECT id, name, email, password, created_at, updated_at FROM users WHERE id = $1` + + var user domain.User + err := querier.QueryRowContext(ctx, query, id).Scan( + &user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrUserNotFound // Infrastructure → Domain } @@ -538,6 +547,102 @@ make docker-run-migrate ## Architecture +### Database Repository Pattern + +The project uses separate repository implementations for MySQL and PostgreSQL, leveraging Go's standard `database/sql` package. This approach provides: + +- **Database-specific optimizations** - Each implementation is tailored to the specific database's features and syntax +- **Type safety** - Direct use of database/sql types without abstraction layers +- **Clarity** - Explicit SQL queries make it clear what operations are being performed +- **No external dependencies** - Uses only the standard library for database operations + +**Repository Interface** (defined in use case layer): + +```go +// internal/user/usecase/user_usecase.go +type UserRepository interface { + Create(ctx context.Context, user *domain.User) error + GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) + GetByEmail(ctx context.Context, email string) (*domain.User, error) +} +``` + +**MySQL Implementation** (`internal/user/repository/mysql_user_repository.go`): + +```go +func (r *MySQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())` + + // Convert UUID to bytes for MySQL BINARY(16) + uuidBytes, err := user.ID.MarshalBinary() + if err != nil { + return apperrors.Wrap(err, "failed to marshal UUID") + } + + _, err = querier.ExecContext(ctx, query, uuidBytes, user.Name, user.Email, user.Password) + if err != nil { + if isMySQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} +``` + +**PostgreSQL Implementation** (`internal/user/repository/postgresql_user_repository.go`): + +```go +func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.Password) + if err != nil { + if isPostgreSQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} +``` + +**Key Differences:** + +| Feature | MySQL | PostgreSQL | +|---------|-------|------------| +| UUID Storage | `BINARY(16)` - requires marshaling/unmarshaling | Native `UUID` type | +| Placeholders | `?` for all parameters | `$1, $2, $3...` numbered parameters | +| Unique Errors | Check for "1062" or "duplicate entry" | Check for "duplicate key" or "unique constraint" | + +**Transaction Support:** + +The `database.GetTx()` helper function retrieves the transaction from context if available, otherwise returns the DB connection: + +```go +// internal/database/txmanager.go +type Querier interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +func GetTx(ctx context.Context, db *sql.DB) Querier { + if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok { + return tx + } + return db +} +``` + +This pattern ensures repositories work seamlessly within transactions managed by the use case layer. + ### Dependency Injection Container The project uses a custom dependency injection (DI) container located in `internal/app/` to manage all application components. This provides: @@ -621,7 +726,8 @@ internal/product/ ├── usecase/ │ └── product_usecase.go # UseCase interface + business logic ├── repository/ -│ └── product_repository.go # Data access (returns domain errors) +│ ├── mysql_product_repository.go # MySQL data access +│ └── postgresql_product_repository.go # PostgreSQL data access └── http/ ├── dto/ │ ├── request.go # API request DTOs @@ -699,12 +805,21 @@ func (c *Container) ProductUseCase() (productUsecase.UseCase, error) { } // Add initialization methods -func (c *Container) initProductRepository() (*productRepository.ProductRepository, error) { +func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) { db, err := c.DB() if err != nil { return nil, fmt.Errorf("failed to get database: %w", err) } - return productRepository.NewProductRepository(db, c.config.DBDriver), nil + + // Select the appropriate repository based on the database driver + switch c.config.DBDriver { + case "mysql": + return productRepository.NewMySQLProductRepository(db), nil + case "postgres": + return productRepository.NewPostgreSQLProductRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) + } } func (c *Container) initProductUseCase() (productUsecase.UseCase, error) { @@ -747,7 +862,7 @@ mux.HandleFunc("/api/products", productHandler.HandleProducts) ### Clean Architecture Layers 1. **Domain Layer** - Contains business entities, domain errors, and rules (e.g., `internal/user/domain`) -2. **Repository Layer** - Data access implementations using sqlutil; transforms infrastructure errors to domain errors (e.g., `internal/user/repository`) +2. **Repository Layer** - Data access implementations using `database/sql`; transforms infrastructure errors to domain errors (e.g., `internal/user/repository`) 3. **Use Case Layer** - UseCase interfaces and application business logic; returns domain errors (e.g., `internal/user/usecase`) 4. **Presentation Layer** - HTTP handlers that map domain errors to HTTP responses (e.g., `internal/user/http`) 5. **Utility Layer** - Shared utilities including error handling and mapping (e.g., `internal/httputil`, `internal/errors`) @@ -1039,7 +1154,6 @@ go tool cover -html=coverage.out - [go-env](https://github.com/allisson/go-env) - Environment variable configuration - [godotenv](https://github.com/joho/godotenv) - Loads environment variables from .env files - [go-pwdhash](https://github.com/allisson/go-pwdhash) - Password hashing with Argon2id -- [sqlutil](https://github.com/allisson/sqlutil) - SQL utilities for unified database access - [validation](https://github.com/jellydator/validation) - Advanced input validation library - [uuid](https://github.com/google/uuid) - UUID generation including UUIDv7 support - [urfave/cli](https://github.com/urfave/cli) - CLI framework @@ -1063,7 +1177,6 @@ Contributions are welcome! Please feel free to submit a Pull Request. This template uses the following excellent Go libraries: - github.com/allisson/go-env - github.com/allisson/go-pwdhash -- github.com/allisson/sqlutil - github.com/jellydator/validation - github.com/urfave/cli - github.com/golang-migrate/migrate \ No newline at end of file diff --git a/cmd/app/main.go b/cmd/app/main.go index be93407..7fa996d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -10,13 +10,14 @@ import ( "os/signal" "syscall" - "github.com/allisson/go-project-template/internal/app" - "github.com/allisson/go-project-template/internal/config" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/mysql" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/urfave/cli/v3" + + "github.com/allisson/go-project-template/internal/app" + "github.com/allisson/go-project-template/internal/config" ) // closeContainer closes all resources in the container and logs any errors. @@ -30,7 +31,11 @@ func closeContainer(container *app.Container, logger *slog.Logger) { func closeMigrate(migrate *migrate.Migrate, logger *slog.Logger) { sourceError, databaseError := migrate.Close() if sourceError != nil || databaseError != nil { - logger.Error("failed to close the migrate", slog.Any("source_error", sourceError), slog.Any("database_error", databaseError)) + logger.Error( + "failed to close the migrate", + slog.Any("source_error", sourceError), + slog.Any("database_error", databaseError), + ) } } diff --git a/go.mod b/go.mod index 2811cd4..16b3d8b 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/allisson/go-env v0.6.0 github.com/allisson/go-pwdhash v0.3.1 - github.com/allisson/sqlutil v1.10.0 github.com/go-sql-driver/mysql v1.9.3 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 @@ -19,13 +18,8 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/allisson/sqlquery v1.5.0 // indirect github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/georgysavva/scany/v2 v2.1.4 // indirect - github.com/huandu/go-clone v1.7.3 // indirect - github.com/huandu/go-sqlbuilder v1.39.0 // indirect - github.com/huandu/xstrings v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/stretchr/objx v0.5.2 // indirect golang.org/x/crypto v0.47.0 // indirect diff --git a/go.sum b/go.sum index bd492fc..7380540 100644 --- a/go.sum +++ b/go.sum @@ -10,22 +10,15 @@ github.com/allisson/go-env v0.6.0 h1:YaWmnOjhF+0c7GjgJef4LC0XymV12EIoVxJHpHGnGnU github.com/allisson/go-env v0.6.0/go.mod h1:9XxzBNupzMpZ329C9ZPKIhyI7uCIyhST+/rOFvJpdjQ= github.com/allisson/go-pwdhash v0.3.1 h1:UzR/0V77E6l63fV6EuAUj0nj1S2jdGADzgoO7UBgaT0= github.com/allisson/go-pwdhash v0.3.1/go.mod h1:qMlMlCyJ2zwSV8Df406IKgY4VC/39FpiaLamOmZezYU= -github.com/allisson/sqlquery v1.5.0 h1:fPSpwWIelpSXcrVbQpX1qNjzmVcMZw7A3FUgntYnHmM= -github.com/allisson/sqlquery v1.5.0/go.mod h1:PbwTeUaIvV3r+8Q50eBxx3ExgjhALczLoY+NZGCS4j4= -github.com/allisson/sqlutil v1.10.0 h1:MIqF/HpqDtLsXBhpTsEPGjymMwuvP3gyvcLlDV11MIk= -github.com/allisson/sqlutil v1.10.0/go.mod h1:mjhAiULaVhFs0FHH4/psiBXPN5Yv/bUIWTF1aW4GZnU= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0= github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs= -github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= -github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= @@ -40,43 +33,18 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= -github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= -github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= -github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= -github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= -github.com/huandu/go-sqlbuilder v1.39.0 h1:O3eSJZXrOfysA1SoDTf/sCiZqhA/FvdKRnehYwhrdOA= -github.com/huandu/go-sqlbuilder v1.39.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= -github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= -github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= -github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jellydator/validation v1.2.0 h1:z3P3Hk5kdT9epXDraWAfMZtOIUM7UQ0PkNAnFEUjcAk= github.com/jellydator/validation v1.2.0/go.mod h1:AaCjfkQ4Ykdcb+YCwqCtaI3wDsf2UAGhJ06lJs0VgOw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -84,8 +52,6 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= -github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -121,12 +87,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/app/README.md b/internal/app/README.md index 21bedee..bc4462e 100644 --- a/internal/app/README.md +++ b/internal/app/README.md @@ -65,10 +65,14 @@ Container ├── TxManager │ └── depends on: Database ├── Repositories -│ ├── UserRepository -│ │ └── depends on: Database, Config.DBDriver -│ └── OutboxRepository -│ └── depends on: Database, Config.DBDriver +│ ├── UserRepository (interface from usecase package) +│ │ ├── MySQLUserRepository (concrete implementation) +│ │ ├── PostgreSQLUserRepository (concrete implementation) +│ │ └── depends on: Database +│ └── OutboxRepository (interface from usecase package) +│ ├── MySQLOutboxEventRepository (concrete implementation) +│ ├── PostgreSQLOutboxEventRepository (concrete implementation) +│ └── depends on: Database ├── Use Cases │ └── UserUseCase │ ├── depends on: TxManager @@ -225,18 +229,21 @@ func (c *Container) OrderUseCase() (*orderUsecase.OrderUseCase, error) { ### 3. Add initialization method ```go -func (c *Container) initOrderUseCase() (*orderUsecase.OrderUseCase, error) { - txManager, err := c.TxManager() +func (c *Container) initProductRepository() (productUsecase.ProductRepository, error) { + db, err := c.DB() if err != nil { - return nil, fmt.Errorf("failed to get tx manager: %w", err) + return nil, fmt.Errorf("failed to get database: %w", err) } - - orderRepo, err := c.OrderRepository() - if err != nil { - return nil, fmt.Errorf("failed to get order repository: %w", err) + + // Select the appropriate repository based on the database driver + switch c.config.DBDriver { + case "mysql": + return productRepository.NewMySQLProductRepository(db), nil + case "postgres": + return productRepository.NewPostgreSQLProductRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) } - - return orderUsecase.NewOrderUseCase(txManager, orderRepo), nil } ``` @@ -253,8 +260,24 @@ The `main.go` file is significantly simpler and focused on application flow rath // 60+ lines of manual dependency wiring db, err := database.Connect(...) txManager := database.NewTxManager(db) -userRepo := userRepository.NewUserRepository(db, cfg.DBDriver) -outboxRepo := outboxRepository.NewOutboxEventRepository(db, cfg.DBDriver) + +// Determine which repository to use +var userRepo userUsecase.UserRepository +switch cfg.DBDriver { +case "mysql": + userRepo = userRepository.NewMySQLUserRepository(db) +case "postgres": + userRepo = userRepository.NewPostgreSQLUserRepository(db) +} + +var outboxRepo userUsecase.OutboxEventRepository +switch cfg.DBDriver { +case "mysql": + outboxRepo = outboxRepository.NewMySQLOutboxEventRepository(db) +case "postgres": + outboxRepo = outboxRepository.NewPostgreSQLOutboxEventRepository(db) +} + userUseCase, err := userUsecase.NewUserUseCase(txManager, userRepo, outboxRepo) server := http.NewServer(cfg.ServerHost, cfg.ServerPort, logger, userUseCase) ``` diff --git a/internal/app/di.go b/internal/app/di.go index 2999e75..e44ed29 100644 --- a/internal/app/di.go +++ b/internal/app/di.go @@ -32,8 +32,8 @@ type Container struct { txManager database.TxManager // Repositories - userRepo *userRepository.UserRepository - outboxRepo *outboxRepository.OutboxEventRepository + userRepo userUsecase.UserRepository + outboxRepo userUsecase.OutboxEventRepository // Use Cases userUseCase userUsecase.UseCase @@ -116,7 +116,7 @@ func (c *Container) TxManager() (database.TxManager, error) { } // UserRepository returns the user repository instance. -func (c *Container) UserRepository() (*userRepository.UserRepository, error) { +func (c *Container) UserRepository() (userUsecase.UserRepository, error) { var err error c.userRepoInit.Do(func() { c.userRepo, err = c.initUserRepository() @@ -134,7 +134,7 @@ func (c *Container) UserRepository() (*userRepository.UserRepository, error) { } // OutboxRepository returns the outbox event repository instance. -func (c *Container) OutboxRepository() (*outboxRepository.OutboxEventRepository, error) { +func (c *Container) OutboxRepository() (userUsecase.OutboxEventRepository, error) { var err error c.outboxRepoInit.Do(func() { c.outboxRepo, err = c.initOutboxRepository() @@ -283,21 +283,39 @@ func (c *Container) initTxManager() (database.TxManager, error) { } // initUserRepository creates the user repository instance. -func (c *Container) initUserRepository() (*userRepository.UserRepository, error) { +func (c *Container) initUserRepository() (userUsecase.UserRepository, error) { db, err := c.DB() if err != nil { return nil, fmt.Errorf("failed to get database for user repository: %w", err) } - return userRepository.NewUserRepository(db, c.config.DBDriver), nil + + // Select the appropriate repository based on the database driver + switch c.config.DBDriver { + case "mysql": + return userRepository.NewMySQLUserRepository(db), nil + case "postgres": + return userRepository.NewPostgreSQLUserRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) + } } // initOutboxRepository creates the outbox event repository instance. -func (c *Container) initOutboxRepository() (*outboxRepository.OutboxEventRepository, error) { +func (c *Container) initOutboxRepository() (userUsecase.OutboxEventRepository, error) { db, err := c.DB() if err != nil { return nil, fmt.Errorf("failed to get database for outbox repository: %w", err) } - return outboxRepository.NewOutboxEventRepository(db, c.config.DBDriver), nil + + // Select the appropriate repository based on the database driver + switch c.config.DBDriver { + case "mysql": + return outboxRepository.NewMySQLOutboxEventRepository(db), nil + case "postgres": + return outboxRepository.NewPostgreSQLOutboxEventRepository(db), nil + default: + return nil, fmt.Errorf("unsupported database driver: %s", c.config.DBDriver) + } } // initUserUseCase creates the user use case with all its dependencies. diff --git a/internal/config/config.go b/internal/config/config.go index b41c415..ac37999 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,8 +46,11 @@ func Load() *Config { ServerPort: env.GetInt("SERVER_PORT", 8080), // Database configuration - DBDriver: env.GetString("DB_DRIVER", "postgres"), - DBConnectionString: env.GetString("DB_CONNECTION_STRING", "postgres://user:password@localhost:5432/mydb?sslmode=disable"), + DBDriver: env.GetString("DB_DRIVER", "postgres"), + DBConnectionString: env.GetString( + "DB_CONNECTION_STRING", + "postgres://user:password@localhost:5432/mydb?sslmode=disable", + ), DBMaxOpenConnections: env.GetInt("DB_MAX_OPEN_CONNECTIONS", 25), DBMaxIdleConnections: env.GetInt("DB_MAX_IDLE_CONNECTIONS", 5), DBConnMaxLifetime: env.GetDuration("DB_CONN_MAX_LIFETIME", 5, time.Minute), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 72eabee..17e647e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,7 +22,11 @@ func TestLoad(t *testing.T) { assert.Equal(t, "0.0.0.0", cfg.ServerHost) assert.Equal(t, 8080, cfg.ServerPort) assert.Equal(t, "postgres", cfg.DBDriver) - assert.Equal(t, "postgres://user:password@localhost:5432/mydb?sslmode=disable", cfg.DBConnectionString) + assert.Equal( + t, + "postgres://user:password@localhost:5432/mydb?sslmode=disable", + cfg.DBConnectionString, + ) assert.Equal(t, 25, cfg.DBMaxOpenConnections) assert.Equal(t, 5, cfg.DBMaxIdleConnections) assert.Equal(t, 5*time.Minute, cfg.DBConnMaxLifetime) diff --git a/internal/database/txmanager.go b/internal/database/txmanager.go index 9a808d1..df38c26 100644 --- a/internal/database/txmanager.go +++ b/internal/database/txmanager.go @@ -4,13 +4,19 @@ package database import ( "context" "database/sql" - - "github.com/allisson/sqlutil" ) // txKey is a context key type for storing database transactions. type txKey struct{} +// Querier is an interface that represents a database query executor. +// It can be either *sql.DB or *sql.Tx +type Querier interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + // TxManager manages database transactions type TxManager interface { WithTx(ctx context.Context, fn func(ctx context.Context) error) error @@ -46,7 +52,7 @@ func (m *sqlTxManager) WithTx(ctx context.Context, fn func(ctx context.Context) } // GetTx retrieves a transaction from context, or falls back to the DB connection -func GetTx(ctx context.Context, db *sql.DB) sqlutil.Querier { +func GetTx(ctx context.Context, db *sql.DB) Querier { if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok { return tx } diff --git a/internal/http/http_test.go b/internal/http/http_test.go index 1836a6d..a7379b9 100644 --- a/internal/http/http_test.go +++ b/internal/http/http_test.go @@ -11,15 +11,16 @@ import ( "os" "testing" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/allisson/go-project-template/internal/httputil" userDomain "github.com/allisson/go-project-template/internal/user/domain" userHttp "github.com/allisson/go-project-template/internal/user/http" "github.com/allisson/go-project-template/internal/user/http/dto" userUsecase "github.com/allisson/go-project-template/internal/user/usecase" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) // MockUserUseCase is a mock implementation of usecase.UserUseCase @@ -27,7 +28,10 @@ type MockUserUseCase struct { mock.Mock } -func (m *MockUserUseCase) RegisterUser(ctx context.Context, input userUsecase.RegisterUserInput) (*userDomain.User, error) { +func (m *MockUserUseCase) RegisterUser( + ctx context.Context, + input userUsecase.RegisterUserInput, +) (*userDomain.User, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) diff --git a/internal/http/middleware.go b/internal/http/middleware.go index e8a1d47..440a500 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -47,7 +47,11 @@ func RecoveryMiddleware(logger *slog.Logger) Middleware { slog.String("method", r.Method), ) - httputil.MakeJSONResponse(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + httputil.MakeJSONResponse( + w, + http.StatusInternalServerError, + map[string]string{"error": "internal server error"}, + ) } }() @@ -91,7 +95,11 @@ func ReadinessHandler(ctx context.Context) http.Handler { // Check if context is cancelled (application is shutting down) select { case <-ctx.Done(): - httputil.MakeJSONResponse(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"}) + httputil.MakeJSONResponse( + w, + http.StatusServiceUnavailable, + map[string]string{"status": "not ready"}, + ) return default: } diff --git a/internal/outbox/domain/outbox_event.go b/internal/outbox/domain/outbox_event.go index 57021d8..b07d31e 100644 --- a/internal/outbox/domain/outbox_event.go +++ b/internal/outbox/domain/outbox_event.go @@ -18,13 +18,13 @@ const ( // OutboxEvent represents an event in the transactional outbox pattern type OutboxEvent struct { - ID uuid.UUID `db:"id" json:"id"` - EventType string `db:"event_type" json:"event_type" fieldtag:"insert,update"` - Payload string `db:"payload" json:"payload" fieldtag:"insert,update"` - Status OutboxEventStatus `db:"status" json:"status" fieldtag:"insert,update"` - Retries int `db:"retries" json:"retries" fieldtag:"insert,update"` - LastError *string `db:"last_error" json:"last_error,omitempty" fieldtag:"insert,update"` + ID uuid.UUID `db:"id" json:"id"` + EventType string `db:"event_type" json:"event_type" fieldtag:"insert,update"` + Payload string `db:"payload" json:"payload" fieldtag:"insert,update"` + Status OutboxEventStatus `db:"status" json:"status" fieldtag:"insert,update"` + Retries int `db:"retries" json:"retries" fieldtag:"insert,update"` + LastError *string `db:"last_error" json:"last_error,omitempty" fieldtag:"insert,update"` ProcessedAt *time.Time `db:"processed_at" json:"processed_at,omitempty" fieldtag:"insert,update"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } diff --git a/internal/outbox/repository/mysql_outbox_repository.go b/internal/outbox/repository/mysql_outbox_repository.go new file mode 100644 index 0000000..f9125f4 --- /dev/null +++ b/internal/outbox/repository/mysql_outbox_repository.go @@ -0,0 +1,108 @@ +// Package repository provides data persistence implementations for outbox entities. +package repository + +import ( + "context" + "database/sql" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/outbox/domain" +) + +// MySQLOutboxEventRepository handles outbox event persistence for MySQL +type MySQLOutboxEventRepository struct { + db *sql.DB +} + +// NewMySQLOutboxEventRepository creates a new MySQLOutboxEventRepository +func NewMySQLOutboxEventRepository(db *sql.DB) *MySQLOutboxEventRepository { + return &MySQLOutboxEventRepository{ + db: db, + } +} + +// Create inserts a new outbox event +func (r *MySQLOutboxEventRepository) Create(ctx context.Context, event *domain.OutboxEvent) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO outbox_events (id, event_type, payload, status, retries, last_error, processed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())` + + // Convert UUID to bytes for MySQL BINARY(16) + idBytes, err := event.ID.MarshalBinary() + if err != nil { + return err + } + + _, err = querier.ExecContext(ctx, query, idBytes, event.EventType, event.Payload, event.Status, + event.Retries, event.LastError, event.ProcessedAt) + + return err +} + +// GetPendingEvents retrieves pending events with limit +func (r *MySQLOutboxEventRepository) GetPendingEvents( + ctx context.Context, + limit int, +) ([]*domain.OutboxEvent, error) { + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, event_type, payload, status, retries, last_error, processed_at, created_at, updated_at + FROM outbox_events + WHERE status = ? + ORDER BY created_at ASC + LIMIT ? + FOR UPDATE SKIP LOCKED` + + rows, err := querier.QueryContext(ctx, query, domain.OutboxEventStatusPending, limit) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + + var events []*domain.OutboxEvent + for rows.Next() { + var event domain.OutboxEvent + var idBytes []byte + + err := rows.Scan(&idBytes, &event.EventType, &event.Payload, &event.Status, + &event.Retries, &event.LastError, &event.ProcessedAt, &event.CreatedAt, &event.UpdatedAt) + if err != nil { + return nil, err + } + + // Convert bytes back to UUID + if err := event.ID.UnmarshalBinary(idBytes); err != nil { + return nil, err + } + + events = append(events, &event) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return events, nil +} + +// Update updates an outbox event +func (r *MySQLOutboxEventRepository) Update(ctx context.Context, event *domain.OutboxEvent) error { + querier := database.GetTx(ctx, r.db) + + query := `UPDATE outbox_events + SET event_type = ?, payload = ?, status = ?, retries = ?, last_error = ?, + processed_at = ?, updated_at = NOW() + WHERE id = ?` + + // Convert UUID to bytes for MySQL BINARY(16) + idBytes, err := event.ID.MarshalBinary() + if err != nil { + return err + } + + _, err = querier.ExecContext(ctx, query, event.EventType, event.Payload, event.Status, + event.Retries, event.LastError, event.ProcessedAt, idBytes) + + return err +} diff --git a/internal/outbox/repository/mysql_outbox_repository_test.go b/internal/outbox/repository/mysql_outbox_repository_test.go new file mode 100644 index 0000000..1b84dab --- /dev/null +++ b/internal/outbox/repository/mysql_outbox_repository_test.go @@ -0,0 +1,174 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/allisson/go-project-template/internal/outbox/domain" +) + +func TestNewMySQLOutboxEventRepository(t *testing.T) { + db, _, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + assert.NotNil(t, repo) + assert.Equal(t, db, repo.db) +} + +func TestMySQLOutboxEventRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + ctx := context.Background() + + uuid1 := uuid.Must(uuid.NewV7()) + event := &domain.OutboxEvent{ + ID: uuid1, + EventType: "user.created", + Payload: `{"id": 1}`, + Status: domain.OutboxEventStatusPending, + Retries: 0, + } + + idBytes, _ := uuid1.MarshalBinary() + mock.ExpectExec("INSERT INTO outbox_events"). + WithArgs(idBytes, event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = repo.Create(ctx, event) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLOutboxEventRepository_GetPendingEvents(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + ctx := context.Background() + + now := time.Now() + uuid1 := uuid.Must(uuid.NewV7()) + uuid2 := uuid.Must(uuid.NewV7()) + + idBytes1, _ := uuid1.MarshalBinary() + idBytes2, _ := uuid2.MarshalBinary() + + rows := sqlmock.NewRows([]string{"id", "event_type", "payload", "status", "retries", "last_error", "processed_at", "created_at", "updated_at"}). + AddRow(idBytes1, "user.created", `{"id": 1}`, domain.OutboxEventStatusPending, 0, nil, nil, now, now). + AddRow(idBytes2, "user.created", `{"id": 2}`, domain.OutboxEventStatusPending, 0, nil, nil, now.Add(time.Minute), now.Add(time.Minute)) + + mock.ExpectQuery("SELECT (.+) FROM outbox_events"). + WithArgs(domain.OutboxEventStatusPending, 10). + WillReturnRows(rows) + + events, err := repo.GetPendingEvents(ctx, 10) + assert.NoError(t, err) + assert.NotNil(t, events) + assert.Len(t, events, 2) + assert.Equal(t, uuid1, events[0].ID) + assert.Equal(t, uuid2, events[1].ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLOutboxEventRepository_GetPendingEvents_Empty(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + ctx := context.Background() + + rows := sqlmock.NewRows( + []string{ + "id", + "event_type", + "payload", + "status", + "retries", + "last_error", + "processed_at", + "created_at", + "updated_at", + }, + ) + + mock.ExpectQuery("SELECT (.+) FROM outbox_events"). + WithArgs(domain.OutboxEventStatusPending, 10). + WillReturnRows(rows) + + events, err := repo.GetPendingEvents(ctx, 10) + assert.NoError(t, err) + assert.Len(t, events, 0) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLOutboxEventRepository_Update(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + ctx := context.Background() + + now := time.Now() + uuid1 := uuid.Must(uuid.NewV7()) + event := &domain.OutboxEvent{ + ID: uuid1, + EventType: "user.created", + Payload: `{"id": 1}`, + Status: domain.OutboxEventStatusProcessed, + Retries: 0, + ProcessedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } + + idBytes, _ := uuid1.MarshalBinary() + mock.ExpectExec("UPDATE outbox_events"). + WithArgs(event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt, idBytes). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err = repo.Update(ctx, event) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLOutboxEventRepository_Update_Error(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLOutboxEventRepository(db) + ctx := context.Background() + + uuid1 := uuid.Must(uuid.NewV7()) + event := &domain.OutboxEvent{ + ID: uuid1, + EventType: "user.created", + Payload: `{"id": 1}`, + Status: domain.OutboxEventStatusProcessed, + } + + idBytes, _ := uuid1.MarshalBinary() + updateError := assert.AnError + mock.ExpectExec("UPDATE outbox_events"). + WithArgs(event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt, idBytes). + WillReturnError(updateError) + + err = repo.Update(ctx, event) + assert.Error(t, err) + assert.Equal(t, updateError, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/outbox/repository/outbox_repository.go b/internal/outbox/repository/outbox_repository.go deleted file mode 100644 index 49665dd..0000000 --- a/internal/outbox/repository/outbox_repository.go +++ /dev/null @@ -1,57 +0,0 @@ -// Package repository provides data persistence implementations for outbox entities. -package repository - -import ( - "context" - "database/sql" - - "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/outbox/domain" - "github.com/allisson/sqlutil" -) - -// OutboxEventRepository handles outbox event persistence -type OutboxEventRepository struct { - db *sql.DB - flavor sqlutil.Flavor -} - -// NewOutboxEventRepository creates a new OutboxEventRepository -func NewOutboxEventRepository(db *sql.DB, driver string) *OutboxEventRepository { - flavor := sqlutil.PostgreSQLFlavor - if driver == "mysql" { - flavor = sqlutil.MySQLFlavor - } - return &OutboxEventRepository{ - db: db, - flavor: flavor, - } -} - -// Create inserts a new outbox event -func (r *OutboxEventRepository) Create(ctx context.Context, event *domain.OutboxEvent) error { - querier := database.GetTx(ctx, r.db) - return sqlutil.Insert(ctx, querier, r.flavor, "insert", "outbox_events", event) -} - -// GetPendingEvents retrieves pending events with limit -func (r *OutboxEventRepository) GetPendingEvents(ctx context.Context, limit int) ([]*domain.OutboxEvent, error) { - var events []*domain.OutboxEvent - opts := sqlutil.NewFindAllOptions(r.flavor). - WithFilter("status", domain.OutboxEventStatusPending). - WithOrderBy("created_at ASC"). - WithLimit(limit). - WithForUpdate("SKIP LOCKED") - - querier := database.GetTx(ctx, r.db) - if err := sqlutil.Select(ctx, querier, "outbox_events", opts, &events); err != nil { - return nil, err - } - return events, nil -} - -// Update updates an outbox event -func (r *OutboxEventRepository) Update(ctx context.Context, event *domain.OutboxEvent) error { - querier := database.GetTx(ctx, r.db) - return sqlutil.Update(ctx, querier, r.flavor, "update", "outbox_events", event.ID, event) -} diff --git a/internal/outbox/repository/postgresql_outbox_repository.go b/internal/outbox/repository/postgresql_outbox_repository.go new file mode 100644 index 0000000..c004ed4 --- /dev/null +++ b/internal/outbox/repository/postgresql_outbox_repository.go @@ -0,0 +1,90 @@ +// Package repository provides data persistence implementations for outbox entities. +package repository + +import ( + "context" + "database/sql" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/outbox/domain" +) + +// PostgreSQLOutboxEventRepository handles outbox event persistence for PostgreSQL +type PostgreSQLOutboxEventRepository struct { + db *sql.DB +} + +// NewPostgreSQLOutboxEventRepository creates a new PostgreSQLOutboxEventRepository +func NewPostgreSQLOutboxEventRepository(db *sql.DB) *PostgreSQLOutboxEventRepository { + return &PostgreSQLOutboxEventRepository{ + db: db, + } +} + +// Create inserts a new outbox event +func (r *PostgreSQLOutboxEventRepository) Create(ctx context.Context, event *domain.OutboxEvent) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO outbox_events (id, event_type, payload, status, retries, last_error, processed_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, event.ID, event.EventType, event.Payload, event.Status, + event.Retries, event.LastError, event.ProcessedAt) + + return err +} + +// GetPendingEvents retrieves pending events with limit +func (r *PostgreSQLOutboxEventRepository) GetPendingEvents( + ctx context.Context, + limit int, +) ([]*domain.OutboxEvent, error) { + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, event_type, payload, status, retries, last_error, processed_at, created_at, updated_at + FROM outbox_events + WHERE status = $1 + ORDER BY created_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED` + + rows, err := querier.QueryContext(ctx, query, domain.OutboxEventStatusPending, limit) + if err != nil { + return nil, err + } + defer rows.Close() //nolint:errcheck + + var events []*domain.OutboxEvent + for rows.Next() { + var event domain.OutboxEvent + + err := rows.Scan(&event.ID, &event.EventType, &event.Payload, &event.Status, + &event.Retries, &event.LastError, &event.ProcessedAt, &event.CreatedAt, &event.UpdatedAt) + if err != nil { + return nil, err + } + + events = append(events, &event) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return events, nil +} + +// Update updates an outbox event +func (r *PostgreSQLOutboxEventRepository) Update(ctx context.Context, event *domain.OutboxEvent) error { + querier := database.GetTx(ctx, r.db) + + query := `UPDATE outbox_events + SET event_type = $1, payload = $2, status = $3, retries = $4, last_error = $5, + processed_at = $6, updated_at = NOW() + WHERE id = $7` + + _, err := querier.ExecContext(ctx, query, event.EventType, event.Payload, event.Status, + event.Retries, event.LastError, event.ProcessedAt, event.ID) + + return err +} diff --git a/internal/outbox/repository/outbox_repository_test.go b/internal/outbox/repository/postgresql_outbox_repository_test.go similarity index 58% rename from internal/outbox/repository/outbox_repository_test.go rename to internal/outbox/repository/postgresql_outbox_repository_test.go index a8dae5c..97d110c 100644 --- a/internal/outbox/repository/outbox_repository_test.go +++ b/internal/outbox/repository/postgresql_outbox_repository_test.go @@ -6,49 +6,34 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/allisson/go-project-template/internal/outbox/domain" ) -func TestNewOutboxEventRepository(t *testing.T) { +func TestNewPostgreSQLOutboxEventRepository(t *testing.T) { db, _, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - tests := []struct { - name string - driver string - }{ - { - name: "create repository with postgres driver", - driver: "postgres", - }, - { - name: "create repository with mysql driver", - driver: "mysql", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - repo := NewOutboxEventRepository(db, tt.driver) - assert.NotNil(t, repo) - assert.Equal(t, db, repo.db) - }) - } + repo := NewPostgreSQLOutboxEventRepository(db) + assert.NotNil(t, repo) + assert.Equal(t, db, repo.db) } -func TestOutboxEventRepository_Create(t *testing.T) { +func TestPostgreSQLOutboxEventRepository_Create(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewOutboxEventRepository(db, "postgres") + repo := NewPostgreSQLOutboxEventRepository(db) ctx := context.Background() + uuid1 := uuid.Must(uuid.NewV7()) event := &domain.OutboxEvent{ + ID: uuid1, EventType: "user.created", Payload: `{"id": 1}`, Status: domain.OutboxEventStatusPending, @@ -56,7 +41,7 @@ func TestOutboxEventRepository_Create(t *testing.T) { } mock.ExpectExec("INSERT INTO outbox_events"). - WithArgs(event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt). + WithArgs(event.ID, event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt). WillReturnResult(sqlmock.NewResult(1, 1)) err = repo.Create(ctx, event) @@ -64,69 +49,59 @@ func TestOutboxEventRepository_Create(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestOutboxEventRepository_GetPendingEvents(t *testing.T) { +func TestPostgreSQLOutboxEventRepository_GetPendingEvents(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewOutboxEventRepository(db, "postgres") + repo := NewPostgreSQLOutboxEventRepository(db) ctx := context.Background() now := time.Now() uuid1 := uuid.Must(uuid.NewV7()) uuid2 := uuid.Must(uuid.NewV7()) - expectedEvents := []*domain.OutboxEvent{ - { - ID: uuid1, - EventType: "user.created", - Payload: `{"id": 1}`, - Status: domain.OutboxEventStatusPending, - Retries: 0, - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: uuid2, - EventType: "user.created", - Payload: `{"id": 2}`, - Status: domain.OutboxEventStatusPending, - Retries: 0, - CreatedAt: now.Add(time.Minute), - UpdatedAt: now.Add(time.Minute), - }, - } - rows := sqlmock.NewRows([]string{"id", "event_type", "payload", "status", "retries", "last_error", "processed_at", "created_at", "updated_at"}) - for _, event := range expectedEvents { - rows.AddRow(event.ID, event.EventType, event.Payload, event.Status, event.Retries, event.LastError, event.ProcessedAt, event.CreatedAt, event.UpdatedAt) - } + rows := sqlmock.NewRows([]string{"id", "event_type", "payload", "status", "retries", "last_error", "processed_at", "created_at", "updated_at"}). + AddRow(uuid1, "user.created", `{"id": 1}`, domain.OutboxEventStatusPending, 0, nil, nil, now, now). + AddRow(uuid2, "user.created", `{"id": 2}`, domain.OutboxEventStatusPending, 0, nil, nil, now.Add(time.Minute), now.Add(time.Minute)) - // Use AnyArg matcher since sqlutil adds multiple query parameters mock.ExpectQuery("SELECT (.+) FROM outbox_events"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs(domain.OutboxEventStatusPending, 10). WillReturnRows(rows) events, err := repo.GetPendingEvents(ctx, 10) assert.NoError(t, err) assert.NotNil(t, events) assert.Len(t, events, 2) - assert.Equal(t, expectedEvents[0].ID, events[0].ID) - assert.Equal(t, expectedEvents[1].ID, events[1].ID) + assert.Equal(t, uuid1, events[0].ID) + assert.Equal(t, uuid2, events[1].ID) assert.NoError(t, mock.ExpectationsWereMet()) } -func TestOutboxEventRepository_GetPendingEvents_Empty(t *testing.T) { +func TestPostgreSQLOutboxEventRepository_GetPendingEvents_Empty(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewOutboxEventRepository(db, "postgres") + repo := NewPostgreSQLOutboxEventRepository(db) ctx := context.Background() - rows := sqlmock.NewRows([]string{"id", "event_type", "payload", "status", "retries", "last_error", "processed_at", "created_at", "updated_at"}) + rows := sqlmock.NewRows( + []string{ + "id", + "event_type", + "payload", + "status", + "retries", + "last_error", + "processed_at", + "created_at", + "updated_at", + }, + ) mock.ExpectQuery("SELECT (.+) FROM outbox_events"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs(domain.OutboxEventStatusPending, 10). WillReturnRows(rows) events, err := repo.GetPendingEvents(ctx, 10) @@ -135,12 +110,12 @@ func TestOutboxEventRepository_GetPendingEvents_Empty(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestOutboxEventRepository_Update(t *testing.T) { +func TestPostgreSQLOutboxEventRepository_Update(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewOutboxEventRepository(db, "postgres") + repo := NewPostgreSQLOutboxEventRepository(db) ctx := context.Background() now := time.Now() @@ -165,12 +140,12 @@ func TestOutboxEventRepository_Update(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestOutboxEventRepository_Update_Error(t *testing.T) { +func TestPostgreSQLOutboxEventRepository_Update_Error(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewOutboxEventRepository(db, "postgres") + repo := NewPostgreSQLOutboxEventRepository(db) ctx := context.Background() uuid1 := uuid.Must(uuid.NewV7()) diff --git a/internal/user/domain/user.go b/internal/user/domain/user.go index 2e57944..8ff5eae 100644 --- a/internal/user/domain/user.go +++ b/internal/user/domain/user.go @@ -4,16 +4,17 @@ package domain import ( "time" - "github.com/allisson/go-project-template/internal/errors" "github.com/google/uuid" + + "github.com/allisson/go-project-template/internal/errors" ) // User represents a user in the system type User struct { ID uuid.UUID `db:"id"` - Name string `db:"name" fieldtag:"insert,update"` - Email string `db:"email" fieldtag:"insert,update"` - Password string `db:"password" fieldtag:"insert,update"` + Name string `db:"name" fieldtag:"insert,update"` + Email string `db:"email" fieldtag:"insert,update"` + Password string `db:"password" fieldtag:"insert,update"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } diff --git a/internal/user/repository/mysql_user_repository.go b/internal/user/repository/mysql_user_repository.go new file mode 100644 index 0000000..0d16b7b --- /dev/null +++ b/internal/user/repository/mysql_user_repository.go @@ -0,0 +1,122 @@ +// Package repository provides data persistence implementations for user entities. +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/google/uuid" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/user/domain" + + apperrors "github.com/allisson/go-project-template/internal/errors" +) + +// MySQLUserRepository handles user persistence for MySQL +type MySQLUserRepository struct { + db *sql.DB +} + +// NewMySQLUserRepository creates a new MySQLUserRepository +func NewMySQLUserRepository(db *sql.DB) *MySQLUserRepository { + return &MySQLUserRepository{ + db: db, + } +} + +// Create inserts a new user +func (r *MySQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())` + + // Convert UUID to bytes for MySQL BINARY(16) + uuidBytes, err := user.ID.MarshalBinary() + if err != nil { + return apperrors.Wrap(err, "failed to marshal UUID") + } + + _, err = querier.ExecContext(ctx, query, uuidBytes, user.Name, user.Email, user.Password) + if err != nil { + // Check for unique constraint violation (duplicate email) + if isMySQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} + +// GetByID retrieves a user by ID +func (r *MySQLUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + var user domain.User + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, name, email, password, created_at, updated_at + FROM users WHERE id = ?` + + // Convert UUID to bytes for MySQL BINARY(16) + uuidBytes, err := id.MarshalBinary() + if err != nil { + return nil, apperrors.Wrap(err, "failed to marshal UUID") + } + + var idBytes []byte + err = querier.QueryRowContext(ctx, query, uuidBytes).Scan( + &idBytes, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by id") + } + + // Convert bytes back to UUID + if err := user.ID.UnmarshalBinary(idBytes); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal UUID") + } + + return &user, nil +} + +// GetByEmail retrieves a user by email +func (r *MySQLUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + var user domain.User + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, name, email, password, created_at, updated_at + FROM users WHERE email = ?` + + var idBytes []byte + err := querier.QueryRowContext(ctx, query, email).Scan( + &idBytes, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by email") + } + + // Convert bytes back to UUID + if err := user.ID.UnmarshalBinary(idBytes); err != nil { + return nil, apperrors.Wrap(err, "failed to unmarshal UUID") + } + + return &user, nil +} + +// isMySQLUniqueViolation checks if the error is a MySQL unique constraint violation +func isMySQLUniqueViolation(err error) bool { + if err == nil { + return false + } + errMsg := strings.ToLower(err.Error()) + // MySQL: "Error 1062: Duplicate entry" + return strings.Contains(errMsg, "duplicate entry") || strings.Contains(errMsg, "1062") +} diff --git a/internal/user/repository/mysql_user_repository_test.go b/internal/user/repository/mysql_user_repository_test.go new file mode 100644 index 0000000..6f7facb --- /dev/null +++ b/internal/user/repository/mysql_user_repository_test.go @@ -0,0 +1,161 @@ +package repository + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apperrors "github.com/allisson/go-project-template/internal/errors" + "github.com/allisson/go-project-template/internal/user/domain" +) + +func TestNewMySQLUserRepository(t *testing.T) { + db, _, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + assert.NotNil(t, repo) + assert.Equal(t, db, repo.db) +} + +func TestMySQLUserRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + ctx := context.Background() + + uuid1 := uuid.Must(uuid.NewV7()) + user := &domain.User{ + ID: uuid1, + Name: "John Doe", + Email: "john@example.com", + Password: "hashed_password", + } + + uuidBytes, _ := uuid1.MarshalBinary() + mock.ExpectExec("INSERT INTO users"). + WithArgs(uuidBytes, user.Name, user.Email, user.Password). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = repo.Create(ctx, user) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLUserRepository_GetByID(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + ctx := context.Background() + + uuid1 := uuid.Must(uuid.NewV7()) + expectedUser := &domain.User{ + ID: uuid1, + Name: "John Doe", + Email: "john@example.com", + Password: "hashed_password", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + uuidBytes, _ := uuid1.MarshalBinary() + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "created_at", "updated_at"}). + AddRow(uuidBytes, expectedUser.Name, expectedUser.Email, expectedUser.Password, expectedUser.CreatedAt, expectedUser.UpdatedAt) + + mock.ExpectQuery("SELECT (.+) FROM users"). + WithArgs(uuidBytes). + WillReturnRows(rows) + + user, err := repo.GetByID(ctx, uuid1) + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, expectedUser.ID, user.ID) + assert.Equal(t, expectedUser.Name, user.Name) + assert.Equal(t, expectedUser.Email, user.Email) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLUserRepository_GetByID_NotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + ctx := context.Background() + + notFoundUUID := uuid.Must(uuid.NewV7()) + uuidBytes, _ := notFoundUUID.MarshalBinary() + mock.ExpectQuery("SELECT (.+) FROM users"). + WithArgs(uuidBytes). + WillReturnError(sql.ErrNoRows) + + user, err := repo.GetByID(ctx, notFoundUUID) + assert.Error(t, err) + assert.Nil(t, user) + assert.True(t, apperrors.Is(err, domain.ErrUserNotFound)) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLUserRepository_GetByEmail(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + ctx := context.Background() + + uuid1 := uuid.Must(uuid.NewV7()) + expectedUser := &domain.User{ + ID: uuid1, + Name: "John Doe", + Email: "john@example.com", + Password: "hashed_password", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + uuidBytes, _ := uuid1.MarshalBinary() + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "created_at", "updated_at"}). + AddRow(uuidBytes, expectedUser.Name, expectedUser.Email, expectedUser.Password, expectedUser.CreatedAt, expectedUser.UpdatedAt) + + mock.ExpectQuery("SELECT (.+) FROM users"). + WithArgs("john@example.com"). + WillReturnRows(rows) + + user, err := repo.GetByEmail(ctx, "john@example.com") + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, expectedUser.ID, user.ID) + assert.Equal(t, expectedUser.Email, user.Email) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestMySQLUserRepository_GetByEmail_NotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() //nolint:errcheck + + repo := NewMySQLUserRepository(db) + ctx := context.Background() + + mock.ExpectQuery("SELECT (.+) FROM users"). + WithArgs("notfound@example.com"). + WillReturnError(sql.ErrNoRows) + + user, err := repo.GetByEmail(ctx, "notfound@example.com") + assert.Error(t, err) + assert.Nil(t, user) + assert.True(t, apperrors.Is(err, domain.ErrUserNotFound)) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/user/repository/postgresql_user_repository.go b/internal/user/repository/postgresql_user_repository.go new file mode 100644 index 0000000..59ebacc --- /dev/null +++ b/internal/user/repository/postgresql_user_repository.go @@ -0,0 +1,98 @@ +// Package repository provides data persistence implementations for user entities. +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/google/uuid" + + "github.com/allisson/go-project-template/internal/database" + "github.com/allisson/go-project-template/internal/user/domain" + + apperrors "github.com/allisson/go-project-template/internal/errors" +) + +// PostgreSQLUserRepository handles user persistence for PostgreSQL +type PostgreSQLUserRepository struct { + db *sql.DB +} + +// NewPostgreSQLUserRepository creates a new PostgreSQLUserRepository +func NewPostgreSQLUserRepository(db *sql.DB) *PostgreSQLUserRepository { + return &PostgreSQLUserRepository{ + db: db, + } +} + +// Create inserts a new user +func (r *PostgreSQLUserRepository) Create(ctx context.Context, user *domain.User) error { + querier := database.GetTx(ctx, r.db) + + query := `INSERT INTO users (id, name, email, password, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())` + + _, err := querier.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.Password) + if err != nil { + // Check for unique constraint violation (duplicate email) + if isPostgreSQLUniqueViolation(err) { + return domain.ErrUserAlreadyExists + } + return apperrors.Wrap(err, "failed to create user") + } + return nil +} + +// GetByID retrieves a user by ID +func (r *PostgreSQLUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { + var user domain.User + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, name, email, password, created_at, updated_at + FROM users WHERE id = $1` + + err := querier.QueryRowContext(ctx, query, id).Scan( + &user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by id") + } + + return &user, nil +} + +// GetByEmail retrieves a user by email +func (r *PostgreSQLUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + var user domain.User + querier := database.GetTx(ctx, r.db) + + query := `SELECT id, name, email, password, created_at, updated_at + FROM users WHERE email = $1` + + err := querier.QueryRowContext(ctx, query, email).Scan( + &user.ID, &user.Name, &user.Email, &user.Password, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, apperrors.Wrap(err, "failed to get user by email") + } + + return &user, nil +} + +// isPostgreSQLUniqueViolation checks if the error is a PostgreSQL unique constraint violation +func isPostgreSQLUniqueViolation(err error) bool { + if err == nil { + return false + } + errMsg := strings.ToLower(err.Error()) + // PostgreSQL: "duplicate key value violates unique constraint" or "pq: duplicate key" + return strings.Contains(errMsg, "duplicate key") || strings.Contains(errMsg, "unique constraint") +} diff --git a/internal/user/repository/user_repository_test.go b/internal/user/repository/postgresql_user_repository_test.go similarity index 79% rename from internal/user/repository/user_repository_test.go rename to internal/user/repository/postgresql_user_repository_test.go index 433cdd7..0f3f76c 100644 --- a/internal/user/repository/user_repository_test.go +++ b/internal/user/repository/postgresql_user_repository_test.go @@ -7,57 +7,42 @@ import ( "time" "github.com/DATA-DOG/go-sqlmock" - apperrors "github.com/allisson/go-project-template/internal/errors" - "github.com/allisson/go-project-template/internal/user/domain" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + apperrors "github.com/allisson/go-project-template/internal/errors" + "github.com/allisson/go-project-template/internal/user/domain" ) -func TestNewUserRepository(t *testing.T) { +func TestNewPostgreSQLUserRepository(t *testing.T) { db, _, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - tests := []struct { - name string - driver string - }{ - { - name: "create repository with postgres driver", - driver: "postgres", - }, - { - name: "create repository with mysql driver", - driver: "mysql", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - repo := NewUserRepository(db, tt.driver) - assert.NotNil(t, repo) - assert.Equal(t, db, repo.db) - }) - } + repo := NewPostgreSQLUserRepository(db) + assert.NotNil(t, repo) + assert.Equal(t, db, repo.db) } -func TestUserRepository_Create(t *testing.T) { +func TestPostgreSQLUserRepository_Create(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewUserRepository(db, "postgres") + repo := NewPostgreSQLUserRepository(db) ctx := context.Background() + uuid1 := uuid.Must(uuid.NewV7()) user := &domain.User{ + ID: uuid1, Name: "John Doe", Email: "john@example.com", Password: "hashed_password", } mock.ExpectExec("INSERT INTO users"). - WithArgs(user.Name, user.Email, user.Password). + WithArgs(user.ID, user.Name, user.Email, user.Password). WillReturnResult(sqlmock.NewResult(1, 1)) err = repo.Create(ctx, user) @@ -65,12 +50,12 @@ func TestUserRepository_Create(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUserRepository_GetByID(t *testing.T) { +func TestPostgreSQLUserRepository_GetByID(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewUserRepository(db, "postgres") + repo := NewPostgreSQLUserRepository(db) ctx := context.Background() uuid1 := uuid.Must(uuid.NewV7()) @@ -99,12 +84,12 @@ func TestUserRepository_GetByID(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUserRepository_GetByID_NotFound(t *testing.T) { +func TestPostgreSQLUserRepository_GetByID_NotFound(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewUserRepository(db, "postgres") + repo := NewPostgreSQLUserRepository(db) ctx := context.Background() notFoundUUID := uuid.Must(uuid.NewV7()) @@ -119,12 +104,12 @@ func TestUserRepository_GetByID_NotFound(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUserRepository_GetByEmail(t *testing.T) { +func TestPostgreSQLUserRepository_GetByEmail(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewUserRepository(db, "postgres") + repo := NewPostgreSQLUserRepository(db) ctx := context.Background() uuid1 := uuid.Must(uuid.NewV7()) @@ -152,12 +137,12 @@ func TestUserRepository_GetByEmail(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } -func TestUserRepository_GetByEmail_NotFound(t *testing.T) { +func TestPostgreSQLUserRepository_GetByEmail_NotFound(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) defer db.Close() //nolint:errcheck - repo := NewUserRepository(db, "postgres") + repo := NewPostgreSQLUserRepository(db) ctx := context.Background() mock.ExpectQuery("SELECT (.+) FROM users"). diff --git a/internal/user/repository/user_repository.go b/internal/user/repository/user_repository.go deleted file mode 100644 index 582df84..0000000 --- a/internal/user/repository/user_repository.go +++ /dev/null @@ -1,90 +0,0 @@ -// Package repository provides data persistence implementations for user entities. -package repository - -import ( - "context" - "database/sql" - "errors" - "strings" - - "github.com/allisson/go-project-template/internal/database" - "github.com/allisson/go-project-template/internal/user/domain" - "github.com/allisson/sqlutil" - "github.com/google/uuid" - - apperrors "github.com/allisson/go-project-template/internal/errors" -) - -// UserRepository handles user persistence -type UserRepository struct { - db *sql.DB - flavor sqlutil.Flavor -} - -// NewUserRepository creates a new UserRepository -func NewUserRepository(db *sql.DB, driver string) *UserRepository { - flavor := sqlutil.PostgreSQLFlavor - if driver == "mysql" { - flavor = sqlutil.MySQLFlavor - } - return &UserRepository{ - db: db, - flavor: flavor, - } -} - -// Create inserts a new user -func (r *UserRepository) Create(ctx context.Context, user *domain.User) error { - querier := database.GetTx(ctx, r.db) - if err := sqlutil.Insert(ctx, querier, r.flavor, "insert", "users", user); err != nil { - // Check for unique constraint violation (duplicate email) - if isUniqueViolation(err) { - return domain.ErrUserAlreadyExists - } - return apperrors.Wrap(err, "failed to create user") - } - return nil -} - -// GetByID retrieves a user by ID -func (r *UserRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { - var user domain.User - opts := sqlutil.NewFindOptions(r.flavor).WithFilter("id", id) - querier := database.GetTx(ctx, r.db) - if err := sqlutil.Get(ctx, querier, "users", opts, &user); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, domain.ErrUserNotFound - } - return nil, apperrors.Wrap(err, "failed to get user by id") - } - return &user, nil -} - -// GetByEmail retrieves a user by email -func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { - var user domain.User - opts := sqlutil.NewFindOptions(r.flavor).WithFilter("email", email) - querier := database.GetTx(ctx, r.db) - if err := sqlutil.Get(ctx, querier, "users", opts, &user); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, domain.ErrUserNotFound - } - return nil, apperrors.Wrap(err, "failed to get user by email") - } - return &user, nil -} - -// isUniqueViolation checks if the error is a unique constraint violation. -// This works for both PostgreSQL and MySQL drivers. -func isUniqueViolation(err error) bool { - if err == nil { - return false - } - errMsg := strings.ToLower(err.Error()) - // PostgreSQL: "duplicate key value violates unique constraint" or "pq: duplicate key" - // MySQL: "Error 1062: Duplicate entry" - return strings.Contains(errMsg, "duplicate key") || - strings.Contains(errMsg, "unique constraint") || - strings.Contains(errMsg, "duplicate entry") || - strings.Contains(errMsg, "1062") -} diff --git a/internal/user/usecase/user_usecase.go b/internal/user/usecase/user_usecase.go index f8c6e39..59a1a6a 100644 --- a/internal/user/usecase/user_usecase.go +++ b/internal/user/usecase/user_usecase.go @@ -8,13 +8,14 @@ import ( validation "github.com/jellydator/validation" + "github.com/allisson/go-pwdhash" + "github.com/google/uuid" + "github.com/allisson/go-project-template/internal/database" apperrors "github.com/allisson/go-project-template/internal/errors" outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/allisson/go-project-template/internal/user/domain" appValidation "github.com/allisson/go-project-template/internal/validation" - "github.com/allisson/go-pwdhash" - "github.com/google/uuid" ) // RegisterUserInput contains the input data for user registration diff --git a/internal/user/usecase/user_usecase_test.go b/internal/user/usecase/user_usecase_test.go index 81ed39f..7234f73 100644 --- a/internal/user/usecase/user_usecase_test.go +++ b/internal/user/usecase/user_usecase_test.go @@ -6,12 +6,13 @@ import ( "errors" "testing" - outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" - "github.com/allisson/go-project-template/internal/user/domain" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + outboxDomain "github.com/allisson/go-project-template/internal/outbox/domain" + "github.com/allisson/go-project-template/internal/user/domain" ) // MockTxManager is a mock implementation of database.TxManager @@ -68,7 +69,10 @@ func (m *MockOutboxEventRepository) Create(ctx context.Context, event *outboxDom return args.Error(0) } -func (m *MockOutboxEventRepository) GetPendingEvents(ctx context.Context, limit int) ([]*outboxDomain.OutboxEvent, error) { +func (m *MockOutboxEventRepository) GetPendingEvents( + ctx context.Context, + limit int, +) ([]*outboxDomain.OutboxEvent, error) { args := m.Called(ctx, limit) if args.Get(0) == nil { return nil, args.Error(1) diff --git a/internal/validation/rules.go b/internal/validation/rules.go index 779e123..f5919da 100644 --- a/internal/validation/rules.go +++ b/internal/validation/rules.go @@ -41,15 +41,24 @@ func (p PasswordStrength) Validate(value interface{}) error { } if len(s) < p.MinLength { - return validation.NewError("validation_password_min_length", "password must be at least "+string(rune(p.MinLength+48))+" characters") + return validation.NewError( + "validation_password_min_length", + "password must be at least "+string(rune(p.MinLength+48))+" characters", + ) } if p.RequireUpper && !hasUpperCase(s) { - return validation.NewError("validation_password_uppercase", "password must contain at least one uppercase letter") + return validation.NewError( + "validation_password_uppercase", + "password must contain at least one uppercase letter", + ) } if p.RequireLower && !hasLowerCase(s) { - return validation.NewError("validation_password_lowercase", "password must contain at least one lowercase letter") + return validation.NewError( + "validation_password_lowercase", + "password must contain at least one lowercase letter", + ) } if p.RequireNumber && !hasNumber(s) { @@ -57,7 +66,10 @@ func (p PasswordStrength) Validate(value interface{}) error { } if p.RequireSpecial && !hasSpecialChar(s) { - return validation.NewError("validation_password_special", "password must contain at least one special character") + return validation.NewError( + "validation_password_special", + "password must contain at least one special character", + ) } return nil diff --git a/internal/worker/event_worker_test.go b/internal/worker/event_worker_test.go index 8241d6f..6b94e8f 100644 --- a/internal/worker/event_worker_test.go +++ b/internal/worker/event_worker_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" - "github.com/allisson/go-project-template/internal/outbox/domain" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/allisson/go-project-template/internal/outbox/domain" ) // MockTxManager is a mock implementation of database.TxManager @@ -36,7 +37,10 @@ func (m *MockOutboxEventRepository) Create(ctx context.Context, event *domain.Ou return args.Error(0) } -func (m *MockOutboxEventRepository) GetPendingEvents(ctx context.Context, limit int) ([]*domain.OutboxEvent, error) { +func (m *MockOutboxEventRepository) GetPendingEvents( + ctx context.Context, + limit int, +) ([]*domain.OutboxEvent, error) { args := m.Called(ctx, limit) if args.Get(0) == nil { return nil, args.Error(1)