Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3044f2bd00
|
|||
|
f43fc8056c
|
|||
|
9d8762458c
|
39
CHANGELOG.md
39
CHANGELOG.md
@@ -5,6 +5,45 @@ 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/),
|
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).
|
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-05-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Client.BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error)` — starts a transaction with explicit isolation level and read-only options; accepts any `*sql.TxOptions` supported by `database/sql`.
|
||||||
|
- `Component.Stats() sql.DBStats` — returns connection pool metrics (`OpenConnections`, `WaitCount`, `WaitDuration`, etc.) for observability and alerting; returns zero value when called before `OnInit`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Client.Begin(ctx context.Context) (Tx, error)` — refactored as a convenience wrapper calling `BeginTx(ctx, nil)`; behavior is identical, no breaking change.
|
||||||
|
- All micro-lib dependencies bumped from v0.9.1 to v1.0.0: `logz`, `health`, `launcher`, `xerrors`.
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
All other API (`Executor`, `Tx`, `Client`, `Component`, `UnitOfWork`, `Config`, `New`,
|
||||||
|
`NewUnitOfWork`, `HandleError`) is API-compatible with v0.9.1.
|
||||||
|
|
||||||
|
[1.0.0]: https://code.nochebuena.dev/go/mysql/releases/tag/v1.0.0
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-03-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Config.Charset string` (`MYSQL_CHARSET`, default `"utf8mb4"`): connection character set, sent as `SET NAMES <charset>` during handshake. Previously hardcoded to `utf8mb4`.
|
||||||
|
- `Config.Loc string` (`MYSQL_LOC`, default `"UTC"`): IANA timezone name used for `time.Time` ↔ `DATE`/`DATETIME` conversion. Previously hardcoded to `UTC`.
|
||||||
|
- `Config.ParseTime string` (`MYSQL_PARSE_TIME`, default `"true"`): controls driver-level `DATE`/`DATETIME` → `time.Time` mapping. Valid values `"true"` / `"false"`. Previously hardcoded to `true`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Config.DSN()`: now derives `charset`, `loc`, and `parseTime` from the new Config fields instead of hardcoded literals. Empty fields fall back to their respective defaults, preserving identical DSN output for callers that do not set the new fields.
|
||||||
|
- Removed unused `net/url.URL` construction from `DSN()`; the method now builds params directly with `url.Values`.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Backward compatible.** Existing `Config` literals that do not set `Charset`, `Loc`, or `ParseTime` produce the same DSN as v0.9.0 (`charset=utf8mb4&loc=UTC&parseTime=true`).
|
||||||
|
- **Collation via DSN is not supported.** `go-sql-driver` v1.8.x negotiates the collation using a 1-byte handshake ID (max 255). MariaDB 11.4+ collations such as `utf8mb4_uca1400_as_cs` carry IDs > 255 and will cause a connection error if set in the DSN. Set the desired collation in schema migrations at the database/table level.
|
||||||
|
|
||||||
|
[0.9.1]: https://code.nochebuena.dev/go/mysql/releases/tag/v0.9.1
|
||||||
|
|
||||||
## [0.9.0] - 2026-03-18
|
## [0.9.0] - 2026-03-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Provides a `Component` that manages a `*sql.DB` connection pool, satisfies the `
|
|||||||
- **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.
|
- **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`.
|
- **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`.
|
- **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`.
|
||||||
|
- **Configurable DSN parameters (v0.9.1)**: `Config.Charset`, `Config.Loc`, and `Config.ParseTime` are optional string fields that control the corresponding DSN parameters. Empty value means "use the safe default" (`utf8mb4`, `UTC`, `true`). Existing `Config` literals that do not set these fields behave identically to v0.9.0.
|
||||||
|
- **Collation DSN limitation**: `go-sql-driver` v1.8.x negotiates the connection collation via a 1-byte handshake ID (max 255). MariaDB 11.4+ collations such as `utf8mb4_uca1400_as_cs` exceed that range and cannot be specified in the DSN. Set collation at the schema level (database/table DDL) instead.
|
||||||
|
|
||||||
## Patterns
|
## Patterns
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ defer rows.Close()
|
|||||||
- Do not match MySQL errors by message string. Use `HandleError` which switches on `mysqldrv.MySQLError.Number`.
|
- 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 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.
|
- Do not forget `defer rows.Close()` after `QueryContext` — unclosed `*sql.Rows` hold connections from the pool.
|
||||||
|
- Do not pass a MariaDB 11.4+ collation (e.g. `utf8mb4_uca1400_as_cs`) as `Config.Collation` or any DSN parameter — the driver will fail at connect time with "unknown collation". Set collation in schema migrations at the database/table level instead.
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -1,12 +1,12 @@
|
|||||||
module code.nochebuena.dev/go/mysql
|
module code.nochebuena.dev/go/mysql
|
||||||
|
|
||||||
go 1.25
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.nochebuena.dev/go/health v0.9.0
|
code.nochebuena.dev/go/health v1.0.0
|
||||||
code.nochebuena.dev/go/launcher v0.9.0
|
code.nochebuena.dev/go/launcher v1.0.0
|
||||||
code.nochebuena.dev/go/logz v0.9.0
|
code.nochebuena.dev/go/logz v1.0.0
|
||||||
code.nochebuena.dev/go/xerrors v0.9.0
|
code.nochebuena.dev/go/xerrors v1.0.0
|
||||||
github.com/go-sql-driver/mysql v1.8.1
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -1,11 +1,11 @@
|
|||||||
code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI=
|
code.nochebuena.dev/go/health v1.0.0 h1:MOlvrTj8Go0sVgczo1O68nBplZ2DM9Td4aBJqL4HI10=
|
||||||
code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso=
|
code.nochebuena.dev/go/health v1.0.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso=
|
||||||
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA=
|
code.nochebuena.dev/go/launcher v1.0.0 h1:Jwqdc/1XX9do5CIzZpayC8wqhqiHsbiYJSIrLIpoaZ4=
|
||||||
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g=
|
code.nochebuena.dev/go/launcher v1.0.0/go.mod h1:gD2D+aPKfsKNUsT6YkvjszB2fy0qAwvBRXVAtWa4mxo=
|
||||||
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
|
code.nochebuena.dev/go/logz v1.0.0 h1:DpNvLuVFqyLSVKxaRa799sG8RpHnm1j6dhu4pKiFOvY=
|
||||||
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
|
code.nochebuena.dev/go/logz v1.0.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
|
||||||
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE=
|
code.nochebuena.dev/go/xerrors v1.0.0 h1:si24SFGa7cHwAxbu75AAEB+a3qRmF118F/BM2SFI7VI=
|
||||||
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
|
code.nochebuena.dev/go/xerrors v1.0.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
|||||||
66
mysql.go
66
mysql.go
@@ -33,6 +33,7 @@ type Tx interface {
|
|||||||
type Client interface {
|
type Client interface {
|
||||||
GetExecutor(ctx context.Context) Executor
|
GetExecutor(ctx context.Context) Executor
|
||||||
Begin(ctx context.Context) (Tx, error)
|
Begin(ctx context.Context) (Tx, error)
|
||||||
|
BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error)
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
HandleError(err error) error
|
HandleError(err error) error
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ type Component interface {
|
|||||||
launcher.Component
|
launcher.Component
|
||||||
health.Checkable
|
health.Checkable
|
||||||
Client
|
Client
|
||||||
|
Stats() sql.DBStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnitOfWork wraps operations in a single database transaction.
|
// UnitOfWork wraps operations in a single database transaction.
|
||||||
@@ -50,6 +52,16 @@ type UnitOfWork interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config holds MySQL connection settings.
|
// Config holds MySQL connection settings.
|
||||||
|
//
|
||||||
|
// DSN parameters Charset, Loc, and ParseTime default to "utf8mb4", "UTC", and
|
||||||
|
// "true" respectively when left empty, preserving the behaviour of v0.9.0.
|
||||||
|
// Set them explicitly when you need non-default values (e.g. Loc="Local").
|
||||||
|
//
|
||||||
|
// Note on Collation: go-sql-driver v1.8.x negotiates the connection collation
|
||||||
|
// via a 1-byte handshake ID (max 255). MariaDB 11.4+ collations such as
|
||||||
|
// utf8mb4_uca1400_as_cs carry IDs > 255 and cannot be set through the DSN
|
||||||
|
// collation parameter. Set the desired collation at the database/table level
|
||||||
|
// in your schema migrations instead.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string `env:"MYSQL_HOST,required"`
|
Host string `env:"MYSQL_HOST,required"`
|
||||||
Port int `env:"MYSQL_PORT" envDefault:"3306"`
|
Port int `env:"MYSQL_PORT" envDefault:"3306"`
|
||||||
@@ -60,20 +72,38 @@ type Config struct {
|
|||||||
MinConns int `env:"MYSQL_MIN_CONNS" envDefault:"2"`
|
MinConns int `env:"MYSQL_MIN_CONNS" envDefault:"2"`
|
||||||
MaxConnLifetime string `env:"MYSQL_MAX_CONN_LIFETIME" envDefault:"1h"`
|
MaxConnLifetime string `env:"MYSQL_MAX_CONN_LIFETIME" envDefault:"1h"`
|
||||||
MaxConnIdleTime string `env:"MYSQL_MAX_CONN_IDLE_TIME" envDefault:"30m"`
|
MaxConnIdleTime string `env:"MYSQL_MAX_CONN_IDLE_TIME" envDefault:"30m"`
|
||||||
|
// Charset is the connection character set sent as SET NAMES <charset>.
|
||||||
|
// Defaults to "utf8mb4" when empty.
|
||||||
|
Charset string `env:"MYSQL_CHARSET" envDefault:"utf8mb4"`
|
||||||
|
// Loc is the IANA timezone name used for time.Time ↔ MySQL DATETIME
|
||||||
|
// conversion. Defaults to "UTC" when empty.
|
||||||
|
Loc string `env:"MYSQL_LOC" envDefault:"UTC"`
|
||||||
|
// ParseTime controls whether the driver maps DATE/DATETIME columns to
|
||||||
|
// time.Time. Valid values: "true", "false". Defaults to "true" when empty.
|
||||||
|
ParseTime string `env:"MYSQL_PARSE_TIME" envDefault:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DSN constructs a MySQL DSN from the configuration.
|
// DSN constructs a MySQL DSN from the configuration.
|
||||||
|
// Empty Charset, Loc, and ParseTime fields fall back to their safe defaults
|
||||||
|
// ("utf8mb4", "UTC", "true"), matching the behaviour of v0.9.0.
|
||||||
func (c Config) DSN() string {
|
func (c Config) DSN() string {
|
||||||
u := &url.URL{
|
charset := c.Charset
|
||||||
Scheme: "mysql",
|
if charset == "" {
|
||||||
User: url.UserPassword(c.User, c.Password),
|
charset = "utf8mb4"
|
||||||
Host: fmt.Sprintf("%s:%d", c.Host, c.Port),
|
|
||||||
Path: "/" + c.Name,
|
|
||||||
}
|
}
|
||||||
q := u.Query()
|
loc := c.Loc
|
||||||
q.Set("parseTime", "true")
|
if loc == "" {
|
||||||
q.Set("loc", "UTC")
|
loc = "UTC"
|
||||||
u.RawQuery = q.Encode()
|
}
|
||||||
|
parseTime := c.ParseTime
|
||||||
|
if parseTime == "" {
|
||||||
|
parseTime = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("charset", charset)
|
||||||
|
q.Set("loc", loc)
|
||||||
|
q.Set("parseTime", parseTime)
|
||||||
// go-sql-driver uses user:pass@tcp(host:port)/db?params
|
// go-sql-driver uses user:pass@tcp(host:port)/db?params
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s",
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s",
|
||||||
c.User, c.Password, c.Host, c.Port, c.Name, q.Encode())
|
c.User, c.Password, c.Host, c.Port, c.Name, q.Encode())
|
||||||
@@ -164,20 +194,34 @@ func (c *mysqlComponent) GetExecutor(ctx context.Context) Executor {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *mysqlComponent) Begin(ctx context.Context) (Tx, error) {
|
func (c *mysqlComponent) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
db := c.db
|
db := c.db
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, fmt.Errorf("mysql: not initialized")
|
return nil, fmt.Errorf("mysql: not initialized")
|
||||||
}
|
}
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &mysqlTx{Tx: tx}, nil
|
return &mysqlTx{Tx: tx}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mysqlComponent) Begin(ctx context.Context) (Tx, error) {
|
||||||
|
return c.BeginTx(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mysqlComponent) Stats() sql.DBStats {
|
||||||
|
c.mu.RLock()
|
||||||
|
db := c.db
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if db == nil {
|
||||||
|
return sql.DBStats{}
|
||||||
|
}
|
||||||
|
return db.Stats()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *mysqlComponent) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
func (c *mysqlComponent) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
db := c.db
|
db := c.db
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func (m *mockTx) Rollback() error
|
|||||||
type mockClient struct{ tx *mockTx }
|
type mockClient struct{ tx *mockTx }
|
||||||
|
|
||||||
func (m *mockClient) Begin(ctx context.Context) (Tx, error) { return m.tx, nil }
|
func (m *mockClient) Begin(ctx context.Context) (Tx, error) { return m.tx, nil }
|
||||||
|
func (m *mockClient) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error) { return m.tx, nil }
|
||||||
func (m *mockClient) Ping(ctx context.Context) error { return nil }
|
func (m *mockClient) Ping(ctx context.Context) error { return nil }
|
||||||
func (m *mockClient) HandleError(err error) error { return HandleError(err) }
|
func (m *mockClient) HandleError(err error) error { return HandleError(err) }
|
||||||
func (m *mockClient) GetExecutor(ctx context.Context) Executor {
|
func (m *mockClient) GetExecutor(ctx context.Context) Executor {
|
||||||
@@ -143,6 +144,24 @@ func TestUnitOfWork_InjectsExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- BeginTx / Stats ---
|
||||||
|
|
||||||
|
func TestComponent_BeginTx_NilDB(t *testing.T) {
|
||||||
|
c := &mysqlComponent{logger: newLogger()}
|
||||||
|
_, err := c.BeginTx(context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nil db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_Stats_NilDB(t *testing.T) {
|
||||||
|
c := &mysqlComponent{logger: newLogger()}
|
||||||
|
stats := c.Stats()
|
||||||
|
if stats.MaxOpenConnections != 0 || stats.OpenConnections != 0 {
|
||||||
|
t.Errorf("expected zero DBStats, got %+v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func assertCode(t *testing.T, err error, want xerrors.Code) {
|
func assertCode(t *testing.T, err error, want xerrors.Code) {
|
||||||
|
|||||||
Reference in New Issue
Block a user