From 9d8762458c47f05ad7b0c76b43e8459642e4eee0 Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Fri, 20 Mar 2026 14:12:24 -0600 Subject: [PATCH] feat(config): expose charset, loc, and parseTime as configurable DSN parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add Config.Charset (MYSQL_CHARSET, default "utf8mb4"): connection character set sent as SET NAMES during handshake; previously hardcoded - add Config.Loc (MYSQL_LOC, default "UTC"): IANA timezone for time.Time ↔ DATE/DATETIME conversion; previously hardcoded - add Config.ParseTime (MYSQL_PARSE_TIME, default "true"): driver-level DATE/DATETIME → time.Time mapping; valid values "true"/"false"; previously hardcoded - update DSN() to derive parameters from Config fields with empty-means-default semantics; existing Config literals produce identical DSN output (backward compatible) - remove unused url.URL construction from DSN(); params now built directly via url.Values - document collation DSN limitation in Config godoc, CLAUDE.md, RELEASE.md, CHANGELOG.md: go-sql-driver v1.8.x uses 1-byte handshake collation IDs (max 255); MariaDB 11.4+ collations such as utf8mb4_uca1400_as_cs exceed that range — set collation at the database/table level in schema migrations instead --- CHANGELOG.md | 19 +++++++++++++++++++ CLAUDE.md | 3 +++ mysql.go | 46 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 640d141..f5ecdeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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.1] - 2026-03-20 + +### Added + +- `Config.Charset string` (`MYSQL_CHARSET`, default `"utf8mb4"`): connection character set, sent as `SET NAMES ` 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.0] - 2026-03-18 ### Added @@ -31,4 +49,5 @@ and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - 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.1]: https://code.nochebuena.dev/go/mysql/releases/tag/v0.9.1 [0.9.0]: https://code.nochebuena.dev/go/mysql/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md index 1477220..52b928f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **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`. +- **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 @@ -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 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 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 diff --git a/mysql.go b/mysql.go index 5ff8474..26d7913 100644 --- a/mysql.go +++ b/mysql.go @@ -50,6 +50,16 @@ type UnitOfWork interface { } // 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 { Host string `env:"MYSQL_HOST,required"` Port int `env:"MYSQL_PORT" envDefault:"3306"` @@ -60,20 +70,38 @@ type Config struct { 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"` + // Charset is the connection character set sent as SET NAMES . + // 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. +// 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 { - u := &url.URL{ - Scheme: "mysql", - User: url.UserPassword(c.User, c.Password), - Host: fmt.Sprintf("%s:%d", c.Host, c.Port), - Path: "/" + c.Name, + charset := c.Charset + if charset == "" { + charset = "utf8mb4" } - q := u.Query() - q.Set("parseTime", "true") - q.Set("loc", "UTC") - u.RawQuery = q.Encode() + loc := c.Loc + if loc == "" { + loc = "UTC" + } + 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 return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", c.User, c.Password, c.Host, c.Port, c.Name, q.Encode())