feat(mysql): initial stable release v0.9.0

database/sql-backed MySQL client with launcher lifecycle, health check, unit-of-work via context injection, and structured error mapping.

What's included:
- Executor / Tx / Client / Component interfaces using database/sql native types (sql.Result, *sql.Rows, *sql.Row)
- Tx.Commit() / Tx.Rollback() without ctx, matching the honest database/sql contract
- New(logger, cfg) constructor; *sql.DB opened in OnInit
- Config struct with env-tag support for all pool tuning parameters
- UnitOfWork via context injection; GetExecutor(ctx) returns active *sql.Tx or *sql.DB
- HandleError mapping MySQLError.Number to xerrors codes (1062 → AlreadyExists, 1216/1217/1451/1452 → InvalidInput, ErrNoRows → NotFound)
- Driver imported as mysqldrv alias to avoid package name collision
- health.Checkable at LevelCritical; HealthCheck delegates to db.PingContext

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-19 13:21:34 +00:00
commit 616a4d996b
16 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Executor` interface: `ExecContext`, `QueryContext`, `QueryRowContext` using `database/sql` types (`sql.Result`, `*sql.Rows`, `*sql.Row`).
- `Tx` interface: embeds `Executor` and adds `Commit() error` and `Rollback() error` (no context argument, matching `database/sql` semantics).
- `Client` interface: `GetExecutor(ctx context.Context) Executor`, `Begin(ctx context.Context) (Tx, error)`, `Ping(ctx context.Context) error`, `HandleError(err error) error`.
- `Component` interface: composes `launcher.Component`, `health.Checkable`, and `Client`.
- `UnitOfWork` interface: `Do(ctx context.Context, fn func(ctx context.Context) error) error`.
- `Config` struct: fields `Host`, `Port`, `User`, `Password`, `Name`, `MaxConns`, `MinConns`, `MaxConnLifetime`, `MaxConnIdleTime`; settable via `MYSQL_*` environment variables with defaults (port `3306`, max conns `5`, idle conns `2`, lifetime `1h`, idle time `30m`).
- `Config.DSN() string`: constructs a `go-sql-driver` DSN in `user:pass@tcp(host:port)/db?parseTime=true&loc=UTC` format.
- `New(logger logz.Logger, cfg Config) Component`: returns a `*sql.DB`-backed component; the connection is opened lazily in `OnInit`.
- Lifecycle hooks: `OnInit` calls `sql.Open`, sets pool limits, and parses duration config fields; `OnStart` pings with a 5-second timeout; `OnStop` closes the `*sql.DB`.
- `health.Checkable` implementation: `HealthCheck` delegates to `Ping`; `Name()` returns `"mysql"`; `Priority()` returns `health.LevelCritical`.
- `NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork`: wraps a `Client` to provide transactional `Do` semantics; rolls back and logs on error, commits on success.
- `HandleError(err error) error` (package-level function): maps `*mysqldrv.MySQLError` error numbers to xerrors — `1062` (duplicate entry) → `ErrAlreadyExists`; `1216`, `1217`, `1451`, `1452` (foreign key violations) → `ErrInvalidInput`; `sql.ErrNoRows``ErrNotFound`; all other errors → `ErrInternal`.
- Transaction context injection: the active `*sql.Tx` is stored under an unexported `ctxTxKey{}` context key; `GetExecutor` returns it when found, otherwise returns `*sql.DB`.
- All `*sql.DB` reads guarded by `sync.RWMutex` for safe concurrent access.
- `go-sql-driver/mysql` is imported with a blank identifier in `mysql.go` for driver side-effect registration, and as `mysqldrv` in `errors.go` to avoid the package name collision.
### Design Notes
- `Tx.Commit()` and `Tx.Rollback()` intentionally omit a `context.Context` argument, honestly reflecting the `database/sql` limitation rather than accepting and ignoring one.
- The module is structurally parallel to `postgres` but uses `database/sql` types throughout; the two modules are intentionally type-incompatible.
- MySQL error codes are matched by numeric constant via `MySQLError.Number`, not by string parsing, for stability across MySQL and MariaDB versions.
[0.9.0]: https://code.nochebuena.dev/go/mysql/releases/tag/v0.9.0

71
CLAUDE.md Normal file
View File

@@ -0,0 +1,71 @@
# mysql
database/sql-backed MySQL client with launcher lifecycle, health check integration, and unit-of-work transaction management.
## Purpose
Provides a `Component` that manages a `*sql.DB` connection pool, satisfies the `launcher.Component` lifecycle hooks (`OnInit`, `OnStart`, `OnStop`), and implements `health.Checkable` (priority: critical). Also provides `NewUnitOfWork` for wrapping multiple repository operations in a single transaction via context injection.
## Tier & Dependencies
**Tier 3** (infrastructure) — depends on:
- `code.nochebuena.dev/go/health` (Tier 1)
- `code.nochebuena.dev/go/launcher` (Tier 1)
- `code.nochebuena.dev/go/logz` (Tier 0)
- `code.nochebuena.dev/go/xerrors` (Tier 0)
- `github.com/go-sql-driver/mysql` (external driver)
## Key Design Decisions
- **database/sql native types** (ADR-001): `Executor` uses `sql.Result`, `*sql.Rows`, and `*sql.Row`. Method names follow `database/sql` convention: `ExecContext`, `QueryContext`, `QueryRowContext`. The `postgres` module uses pgx types; these two are intentionally incompatible.
- **No ctx on Tx.Commit/Rollback** (ADR-002): `database/sql` does not support per-call context on `Commit` or `Rollback`. The `mysql.Tx` interface honestly omits `ctx` from these methods rather than accepting and ignoring it. `UnitOfWork.Do` calls `tx.Commit()` and `tx.Rollback()` without context.
- **Driver import alias** (ADR-003): The `go-sql-driver/mysql` driver package name collides with the package name `mysql`. In `errors.go` it is imported as `mysqldrv` to disambiguate. In `mysql.go` it is imported with `_` for side-effect registration only.
- **Error mapping via MySQLError.Number**: `HandleError` type-asserts to `*mysqldrv.MySQLError` and switches on `.Number`. Error codes 1062 (duplicate key) → `ErrAlreadyExists`; 1216, 1217, 1451, 1452 (foreign key violations) → `ErrInvalidInput`. `sql.ErrNoRows``ErrNotFound`.
- **UnitOfWork via context injection**: Same pattern as the `postgres` module — active `*sql.Tx` is stored under `ctxTxKey{}` in the context. `GetExecutor(ctx)` returns the transaction if present, otherwise `*sql.DB`.
## Patterns
Lifecycle registration:
```go
db := mysql.New(logger, cfg)
lc.Append(db)
r.Get("/health", health.NewHandler(logger, db))
```
Unit of Work:
```go
uow := mysql.NewUnitOfWork(logger, db)
err := uow.Do(ctx, func(ctx context.Context) error {
exec := db.GetExecutor(ctx) // returns active *sql.Tx
_, err := exec.ExecContext(ctx, "INSERT INTO ...")
return err
})
```
Error handling in repository code:
```go
rows, err := db.GetExecutor(ctx).QueryContext(ctx, "SELECT ...")
if err != nil {
return db.HandleError(err)
}
defer rows.Close()
```
## What to Avoid
- Do not use pgx types (`pgx.Rows`, `pgconn.CommandTag`, etc.) in code that depends on this module. This is `database/sql`; the two are distinct.
- Do not add a `ctx` parameter to `Tx.Commit()` or `Tx.Rollback()`. `database/sql` does not support it; accepting and silently ignoring a context would be misleading.
- Do not import `github.com/go-sql-driver/mysql` directly in application code to type-assert on `*mysql.MySQLError`. Use `db.HandleError(err)` instead — it maps driver errors to portable `xerrors` codes.
- Do not match MySQL errors by message string. Use `HandleError` which switches on `mysqldrv.MySQLError.Number`.
- Do not add package-level `*sql.DB` variables. `mysqlComponent` is the unit of construction; use dependency injection.
- Do not forget `defer rows.Close()` after `QueryContext` — unclosed `*sql.Rows` hold connections from the pool.
## Testing Notes
- `compliance_test.go` asserts at compile time that `mysql.New(...)` satisfies `mysql.Component`.
- Integration tests (real queries, transaction rollback) require a live MySQL or MariaDB instance, typically provided by a CI service container.
- `HandleError` can be unit-tested by constructing `*mysqldrv.MySQLError{Number: 1062}` directly — no database connection needed.
- Pool initialization happens in `OnInit`, not `New`. Mocking the `Client` interface bypasses pool setup entirely, making unit tests straightforward.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# mysql
`database/sql`-backed MySQL client with launcher lifecycle and health check integration.
## Install
```
go get code.nochebuena.dev/go/mysql
```
## Usage
```go
db := mysql.New(logger, cfg)
lc.Append(db)
r.Get("/health", health.NewHandler(logger, db))
```
## Unit of Work
```go
uow := mysql.NewUnitOfWork(logger, db)
err := uow.Do(ctx, func(ctx context.Context) error {
exec := db.GetExecutor(ctx) // returns the active Tx
_, err := exec.ExecContext(ctx, "INSERT INTO orders ...")
return err
})
```
## Error mapping
```go
if err := db.HandleError(err); err != nil { ... }
```
| MySQL error | xerrors code |
|---|---|
| 1062 (ER_DUP_ENTRY) | `ErrAlreadyExists` |
| 1216/1217/1451/1452 (foreign key) | `ErrInvalidInput` |
| `sql.ErrNoRows` | `ErrNotFound` |
| anything else | `ErrInternal` |
## Configuration
| Env var | Default | Description |
|---|---|---|
| `MYSQL_HOST` | required | Database host |
| `MYSQL_PORT` | `3306` | Database port |
| `MYSQL_USER` | required | Username |
| `MYSQL_PASSWORD` | required | Password |
| `MYSQL_NAME` | required | Database name |
| `MYSQL_MAX_CONNS` | `5` | Max open connections |
| `MYSQL_MIN_CONNS` | `2` | Max idle connections |

8
compliance_test.go Normal file
View File

@@ -0,0 +1,8 @@
package mysql_test
import (
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/mysql"
)
var _ mysql.Component = mysql.New(logz.New(logz.Options{}), mysql.Config{})

8
doc.go Normal file
View File

@@ -0,0 +1,8 @@
// Package mysql provides a database/sql-backed MySQL client with launcher and health integration.
//
// Usage:
//
// db := mysql.New(logger, cfg)
// lc.Append(db)
// r.Get("/health", health.NewHandler(logger, db))
package mysql

View File

@@ -0,0 +1,37 @@
# ADR-001: database/sql Native Types
**Status:** Accepted
**Date:** 2026-03-18
## Context
Go's standard `database/sql` package was designed as a database-agnostic abstraction. Using it for MySQL is the conventional approach: the `go-sql-driver/mysql` package registers itself as a `database/sql` driver, and all query operations are expressed through `*sql.DB`, `*sql.Tx`, `*sql.Rows`, `*sql.Row`, and `sql.Result`.
An alternative would be to use a MySQL-specific client library analogous to pgx for PostgreSQL. However, there is no dominant MySQL-specific client with the same level of ecosystem adoption and capability that pgx has for PostgreSQL. Using `database/sql` with a standard driver is the practical and broadly understood choice.
The `postgres` module deliberately chose pgx native types. MySQL is the counterpart — it uses `database/sql` native types. The two modules are not interchangeable, which is by design.
## Decision
The `mysql` module's `Executor` interface uses `database/sql` types throughout:
```go
type Executor 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
}
```
Method names follow the `database/sql` convention (`ExecContext`, `QueryContext`, `QueryRowContext`) rather than the pgx convention (`Exec`, `Query`, `QueryRow`). This matches the underlying `*sql.DB` and `*sql.Tx` method signatures exactly, meaning `*sql.DB` satisfies `Executor` structurally without any wrapping.
The `mysqlComponent` embeds `*sql.DB` and delegates directly. `mysqlTx` wraps `*sql.Tx` and delegates to it.
## Consequences
- **Positive**: Familiarity — `database/sql` is Go's standard library API, known to most Go developers.
- **Positive**: `*sql.DB` and `*sql.Tx` natively satisfy the `Executor` interface, reducing the need for wrapper code.
- **Positive**: Any `database/sql`-compatible driver (MariaDB, TiDB, etc.) could be substituted by changing the driver registration without touching the interface.
- **Negative**: `sql.Result` is less informative than `pgconn.CommandTag` (no command string, only `LastInsertId` and `RowsAffected`). Applications that need PostgreSQL-style command metadata must use the `postgres` module.
- **Negative**: `*sql.Rows` must be closed after use (`defer rows.Close()`). Forgetting this leaks connections back to the pool.
- **Note**: The method naming difference from `postgres` (`ExecContext` vs `Exec`) is intentional and honest — it matches the actual API of the underlying library rather than artificially unifying the two modules.

View File

@@ -0,0 +1,42 @@
# ADR-002: No Context on Tx.Commit and Tx.Rollback
**Status:** Accepted
**Date:** 2026-03-18
## Context
The `postgres` module's `Tx` interface defines `Commit(ctx context.Context) error` and `Rollback(ctx context.Context) error` because `pgx.Tx` supports context-aware commit and rollback — the operation can be cancelled if the context is already done.
The `database/sql` standard library's `*sql.Tx` does not support this. Its `Commit()` and `Rollback()` methods have no context parameter:
```go
// from database/sql:
func (tx *Tx) Commit() error
func (tx *Tx) Rollback() error
```
Adding a `ctx` parameter to the `mysql.Tx` interface would be a lie: the implementation would have to ignore the context, potentially masking cancellations or deadlines that callers believe they are enforcing.
## Decision
The `mysql.Tx` interface is defined with context-free commit and rollback, matching the `database/sql` contract honestly:
```go
type Tx interface {
Executor
Commit() error
Rollback() error
}
```
The `UnitOfWork.Do` implementation calls `tx.Rollback()` and `tx.Commit()` without passing a context. This is consistent with the interface definition and with `database/sql` semantics.
This means `mysql.Tx` and `postgres.Tx` are not structurally compatible — this is intentional. Callers must not attempt to use one in place of the other.
## Consequences
- **Positive**: The interface is an honest representation of what `database/sql` provides. No silent context-ignore bugs.
- **Positive**: `mysqlTx.Commit()` and `mysqlTx.Rollback()` delegate directly to `*sql.Tx.Commit()` and `*sql.Tx.Rollback()` with no wrapper complexity.
- **Negative**: A commit or rollback cannot be cancelled mid-flight via context in MySQL. If the database is slow or unreachable during commit, the goroutine blocks until the driver-level timeout fires.
- **Negative**: The API asymmetry between `mysql.Tx` and `postgres.Tx` means that generic code written against one cannot be used against the other. This is a known limitation of the design and the reason the two modules are separate.
- **Note**: Go 1.15 added `*sql.Tx.Commit()` and `*sql.Tx.Rollback()` that honour the context passed to `BeginTx` — but still do not accept a per-call context. The limitation is inherent to `database/sql`.

View File

@@ -0,0 +1,47 @@
# ADR-003: Driver Import Alias to Avoid Name Collision
**Status:** Accepted
**Date:** 2026-03-18
## Context
The `errors.go` file in the `mysql` package uses the `go-sql-driver/mysql` package to type-assert MySQL error values:
```go
import "github.com/go-sql-driver/mysql"
```
The Go package name of `github.com/go-sql-driver/mysql` is also `mysql` — the same as the package being authored. This creates an ambiguous identifier: within `errors.go`, unqualified references to `mysql` would be interpreted as the current package, and the driver's `MySQLError` type would be inaccessible without a disambiguating qualifier.
## Decision
The driver is imported under the alias `mysqldrv`:
```go
import mysqldrv "github.com/go-sql-driver/mysql"
```
In `mysql.go`, the driver is imported for its side effect only (registering itself with `database/sql`) using the blank identifier:
```go
import _ "github.com/go-sql-driver/mysql" // register driver
```
The alias `mysqldrv` is used exclusively in `errors.go`, where `MySQLError` must be referenced by name for type assertion via `errors.As`:
```go
var mysqlErr *mysqldrv.MySQLError
if errors.As(err, &mysqlErr) {
switch mysqlErr.Number { ... }
}
```
The alias is chosen to be recognisable — `drv` is a conventional abbreviation for "driver" — while making the boundary between package code and driver code immediately apparent at each call site.
## Consequences
- **Positive**: No ambiguity between the `mysql` package and the `mysql` driver. The alias makes the distinction explicit at every use site.
- **Positive**: The blank import in `mysql.go` documents clearly that the driver is needed only for side-effect registration.
- **Positive**: If the driver package is ever replaced (e.g., with a fork or an alternative driver), only the import alias and the `mysqlErr.Number` switch need to change — the rest of the package is unaffected.
- **Negative**: Readers unfamiliar with the convention must understand that `mysqldrv.MySQLError` refers to the external driver, not the current package. The alias name and the import comment mitigate this.
- **Note**: Error number constants (1062, 1216, 1217, 1451, 1452) are not exported by `go-sql-driver/mysql`, so they are used as integer literals with inline comments identifying the MySQL error name (e.g., `// ER_DUP_ENTRY`).

31
errors.go Normal file
View File

@@ -0,0 +1,31 @@
package mysql
import (
"database/sql"
"errors"
mysqldrv "github.com/go-sql-driver/mysql"
"code.nochebuena.dev/go/xerrors"
)
// HandleError maps MySQL and database/sql errors to xerrors types.
// Also available as client.HandleError(err).
func HandleError(err error) error {
if err == nil {
return nil
}
var mysqlErr *mysqldrv.MySQLError
if errors.As(err, &mysqlErr) {
switch mysqlErr.Number {
case 1062: // ER_DUP_ENTRY
return xerrors.New(xerrors.ErrAlreadyExists, "record already exists").WithError(err)
case 1216, 1217, 1451, 1452: // foreign key violations
return xerrors.New(xerrors.ErrInvalidInput, "data integrity violation").WithError(err)
}
}
if errors.Is(err, sql.ErrNoRows) {
return xerrors.New(xerrors.ErrNotFound, "record not found").WithError(err)
}
return xerrors.New(xerrors.ErrInternal, "unexpected database error").WithError(err)
}

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module code.nochebuena.dev/go/mysql
go 1.25
require (
code.nochebuena.dev/go/health v0.9.0
code.nochebuena.dev/go/launcher v0.9.0
code.nochebuena.dev/go/logz v0.9.0
code.nochebuena.dev/go/xerrors v0.9.0
github.com/go-sql-driver/mysql v1.8.1
)
require filippo.io/edwards25519 v1.1.0 // indirect

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI=
code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso=
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA=
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g=
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE=
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=

250
mysql.go Normal file
View File

@@ -0,0 +1,250 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"net/url"
"sync"
"time"
_ "github.com/go-sql-driver/mysql" // register driver
"code.nochebuena.dev/go/health"
"code.nochebuena.dev/go/launcher"
"code.nochebuena.dev/go/logz"
)
// Executor defines operations shared by pool and transaction.
type Executor 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
}
// Tx extends Executor with commit/rollback (no ctx — sql.Tx limitation).
type Tx interface {
Executor
Commit() error
Rollback() error
}
// Client is the database access interface.
type Client interface {
GetExecutor(ctx context.Context) Executor
Begin(ctx context.Context) (Tx, error)
Ping(ctx context.Context) error
HandleError(err error) error
}
// Component bundles launcher lifecycle, health check, and database client.
type Component interface {
launcher.Component
health.Checkable
Client
}
// UnitOfWork wraps operations in a single database transaction.
type UnitOfWork interface {
Do(ctx context.Context, fn func(ctx context.Context) error) error
}
// Config holds MySQL connection settings.
type Config struct {
Host string `env:"MYSQL_HOST,required"`
Port int `env:"MYSQL_PORT" envDefault:"3306"`
User string `env:"MYSQL_USER,required"`
Password string `env:"MYSQL_PASSWORD,required"`
Name string `env:"MYSQL_NAME,required"`
MaxConns int `env:"MYSQL_MAX_CONNS" envDefault:"5"`
MinConns int `env:"MYSQL_MIN_CONNS" envDefault:"2"`
MaxConnLifetime string `env:"MYSQL_MAX_CONN_LIFETIME" envDefault:"1h"`
MaxConnIdleTime string `env:"MYSQL_MAX_CONN_IDLE_TIME" envDefault:"30m"`
}
// DSN constructs a MySQL DSN from the configuration.
func (c Config) DSN() string {
u := &url.URL{
Scheme: "mysql",
User: url.UserPassword(c.User, c.Password),
Host: fmt.Sprintf("%s:%d", c.Host, c.Port),
Path: "/" + c.Name,
}
q := u.Query()
q.Set("parseTime", "true")
q.Set("loc", "UTC")
u.RawQuery = q.Encode()
// go-sql-driver uses user:pass@tcp(host:port)/db?params
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s",
c.User, c.Password, c.Host, c.Port, c.Name, q.Encode())
}
// ctxTxKey is the context key for the active transaction.
type ctxTxKey struct{}
// --- mysqlComponent ---
type mysqlComponent struct {
logger logz.Logger
cfg Config
db *sql.DB
mu sync.RWMutex
}
// New returns a mysql Component. Call lc.Append(db) to manage its lifecycle.
func New(logger logz.Logger, cfg Config) Component {
return &mysqlComponent{logger: logger, cfg: cfg}
}
func (c *mysqlComponent) OnInit() error {
db, err := sql.Open("mysql", c.cfg.DSN())
if err != nil {
return fmt.Errorf("mysql: open: %w", err)
}
db.SetMaxOpenConns(c.cfg.MaxConns)
db.SetMaxIdleConns(c.cfg.MinConns)
if c.cfg.MaxConnLifetime != "" {
d, err := time.ParseDuration(c.cfg.MaxConnLifetime)
if err != nil {
return fmt.Errorf("MYSQL_MAX_CONN_LIFETIME: %w", err)
}
db.SetConnMaxLifetime(d)
}
if c.cfg.MaxConnIdleTime != "" {
d, err := time.ParseDuration(c.cfg.MaxConnIdleTime)
if err != nil {
return fmt.Errorf("MYSQL_MAX_CONN_IDLE_TIME: %w", err)
}
db.SetConnMaxIdleTime(d)
}
c.mu.Lock()
c.db = db
c.mu.Unlock()
return nil
}
func (c *mysqlComponent) OnStart() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.Ping(ctx); err != nil {
return fmt.Errorf("mysql: ping failed: %w", err)
}
c.logger.Info("mysql: connected")
return nil
}
func (c *mysqlComponent) OnStop() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.db != nil {
c.logger.Info("mysql: closing pool")
_ = c.db.Close()
c.db = nil
}
return nil
}
func (c *mysqlComponent) Ping(ctx context.Context) error {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
if db == nil {
return fmt.Errorf("mysql: not initialized")
}
return db.PingContext(ctx)
}
func (c *mysqlComponent) GetExecutor(ctx context.Context) Executor {
if tx, ok := ctx.Value(ctxTxKey{}).(Executor); ok {
return tx
}
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db
}
func (c *mysqlComponent) Begin(ctx context.Context) (Tx, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
if db == nil {
return nil, fmt.Errorf("mysql: not initialized")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &mysqlTx{Tx: tx}, nil
}
func (c *mysqlComponent) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.ExecContext(ctx, query, args...)
}
func (c *mysqlComponent) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.QueryContext(ctx, query, args...)
}
func (c *mysqlComponent) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.QueryRowContext(ctx, query, args...)
}
func (c *mysqlComponent) HandleError(err error) error { return HandleError(err) }
// health.Checkable
func (c *mysqlComponent) HealthCheck(ctx context.Context) error { return c.Ping(ctx) }
func (c *mysqlComponent) Name() string { return "mysql" }
func (c *mysqlComponent) Priority() health.Level { return health.LevelCritical }
// --- mysqlTx ---
type mysqlTx struct{ *sql.Tx }
func (t *mysqlTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return t.Tx.ExecContext(ctx, query, args...)
}
func (t *mysqlTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return t.Tx.QueryContext(ctx, query, args...)
}
func (t *mysqlTx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return t.Tx.QueryRowContext(ctx, query, args...)
}
func (t *mysqlTx) Commit() error { return t.Tx.Commit() }
func (t *mysqlTx) Rollback() error { return t.Tx.Rollback() }
// --- UnitOfWork ---
type unitOfWork struct {
logger logz.Logger
client Client
}
// NewUnitOfWork returns a UnitOfWork backed by the given client.
func NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork {
return &unitOfWork{logger: logger, client: client}
}
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
tx, err := u.client.Begin(ctx)
if err != nil {
return fmt.Errorf("mysql: begin transaction: %w", err)
}
ctx = context.WithValue(ctx, ctxTxKey{}, tx)
if err := fn(ctx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
u.logger.Error("mysql: rollback failed", rbErr)
}
return err
}
return tx.Commit()
}

166
mysql_test.go Normal file
View File

@@ -0,0 +1,166 @@
package mysql
import (
"context"
"database/sql"
"errors"
"testing"
mysqldrv "github.com/go-sql-driver/mysql"
"code.nochebuena.dev/go/health"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/xerrors"
)
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
// --- New / name / priority ---
func TestNew(t *testing.T) {
if New(newLogger(), Config{}) == nil {
t.Fatal("New returned nil")
}
}
func TestComponent_Name(t *testing.T) {
c := New(newLogger(), Config{}).(health.Checkable)
if c.Name() != "mysql" {
t.Errorf("want mysql, got %s", c.Name())
}
}
func TestComponent_Priority(t *testing.T) {
c := New(newLogger(), Config{}).(health.Checkable)
if c.Priority() != health.LevelCritical {
t.Error("Priority() != LevelCritical")
}
}
func TestComponent_OnStop_NilDB(t *testing.T) {
c := &mysqlComponent{logger: newLogger()}
if err := c.OnStop(); err != nil {
t.Errorf("OnStop with nil db: %v", err)
}
}
// --- Config.DSN ---
func TestConfig_DSN(t *testing.T) {
cfg := Config{Host: "localhost", Port: 3306, User: "root", Password: "pass", Name: "mydb"}
dsn := cfg.DSN()
if dsn == "" {
t.Fatal("DSN empty")
}
for _, want := range []string{"root", "localhost", "3306", "mydb"} {
if !strContains(dsn, want) {
t.Errorf("DSN %q missing %q", dsn, want)
}
}
}
// --- HandleError ---
func TestHandleError_Nil(t *testing.T) {
if err := HandleError(nil); err != nil {
t.Errorf("want nil, got %v", err)
}
}
func TestHandleError_DuplicateEntry(t *testing.T) {
assertCode(t, HandleError(&mysqldrv.MySQLError{Number: 1062}), xerrors.ErrAlreadyExists)
}
func TestHandleError_ForeignKey(t *testing.T) {
assertCode(t, HandleError(&mysqldrv.MySQLError{Number: 1452}), xerrors.ErrInvalidInput)
}
func TestHandleError_NoRows(t *testing.T) {
assertCode(t, HandleError(sql.ErrNoRows), xerrors.ErrNotFound)
}
func TestHandleError_Generic(t *testing.T) {
assertCode(t, HandleError(errors.New("boom")), xerrors.ErrInternal)
}
// --- UnitOfWork ---
type mockTx struct{ committed, rolledBack bool }
func (m *mockTx) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
return nil, nil
}
func (m *mockTx) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
return nil, nil
}
func (m *mockTx) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row { return nil }
func (m *mockTx) Commit() error { m.committed = true; return nil }
func (m *mockTx) Rollback() error { m.rolledBack = true; return nil }
type mockClient struct{ tx *mockTx }
func (m *mockClient) Begin(ctx context.Context) (Tx, error) { return m.tx, nil }
func (m *mockClient) Ping(ctx context.Context) error { return nil }
func (m *mockClient) HandleError(err error) error { return HandleError(err) }
func (m *mockClient) GetExecutor(ctx context.Context) Executor {
if tx, ok := ctx.Value(ctxTxKey{}).(Executor); ok {
return tx
}
return nil
}
func TestUnitOfWork_Commit(t *testing.T) {
tx := &mockTx{}
uow := NewUnitOfWork(newLogger(), &mockClient{tx: tx})
if err := uow.Do(context.Background(), func(ctx context.Context) error { return nil }); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tx.committed {
t.Error("expected Commit")
}
}
func TestUnitOfWork_Rollback(t *testing.T) {
tx := &mockTx{}
uow := NewUnitOfWork(newLogger(), &mockClient{tx: tx})
_ = uow.Do(context.Background(), func(ctx context.Context) error { return errors.New("fail") })
if !tx.rolledBack {
t.Error("expected Rollback")
}
}
func TestUnitOfWork_InjectsExecutor(t *testing.T) {
tx := &mockTx{}
client := &mockClient{tx: tx}
uow := NewUnitOfWork(newLogger(), client)
var got Executor
_ = uow.Do(context.Background(), func(ctx context.Context) error {
got = client.GetExecutor(ctx)
return nil
})
if got != tx {
t.Error("GetExecutor should return the injected Tx")
}
}
// --- helpers ---
func assertCode(t *testing.T, err error, want xerrors.Code) {
t.Helper()
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
}
if xe.Code() != want {
t.Errorf("want %s, got %s", want, xe.Code())
}
}
func strContains(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}