feat: complete contract-driven business development and MySQL integration
This commit is contained in:
69
database/mysql/README.md
Normal file
69
database/mysql/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 官方 MySQL 适配器
|
||||
|
||||
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
|
||||
|
||||
生成项目选择 `--database mysql` 即接入:
|
||||
|
||||
```sh
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
`etc/config.yaml` 使用结构化配置,环境覆盖优先:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
host: db.example.com
|
||||
port: 3306
|
||||
user: app
|
||||
database: billing
|
||||
parseTime: true
|
||||
location: UTC
|
||||
charset: utf8mb4
|
||||
collation: utf8mb4_bin
|
||||
connectTimeout: 5s
|
||||
readTimeout: 5s
|
||||
writeTimeout: 5s
|
||||
tls:
|
||||
mode: required
|
||||
caFile: /etc/certs/database-ca.pem
|
||||
serverName: db.example.com
|
||||
pool:
|
||||
maxOpenConns: 20
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
```
|
||||
|
||||
通过 `GO_OMNI_DB_PASSWORD` 传密码;还有 HOST、PORT、USER、DATABASE、TLS_CA_FILE、TLS_CERT_FILE、TLS_KEY_FILE、POOL_MAX_OPEN_CONNS 等。业务也可自定义 env 标签。新生成服务不再读取 DSN;独立数据库迁移 CLI 和手写 `database.Open/OpenConnector` 的原接口保留。
|
||||
|
||||
默认启用 TLS 并校验系统信任链与主机名;私有 CA 用 caFile,双向 TLS 额外设置 certFile/keyFile。只有明确设置 `tls.mode: disabled` 才使用明文。Unix socket 用 socket,若在 socket 上使用 TLS,必须提供 serverName。不会注册全局 TLS 配置或全局驱动 Logger。
|
||||
|
||||
手写入口:
|
||||
|
||||
```go
|
||||
cfg := mysqladapter.DefaultConfig()
|
||||
// 从项目配置覆盖 cfg;不要打印含密码的完整配置。
|
||||
db, err := mysqladapter.Open(ctx, cfg, mysqladapter.WithLogger(logger))
|
||||
if err != nil { return err }
|
||||
defer db.Close()
|
||||
// db.DB 是标准 *sql.DB;也可用框架 Health、InTx 等方法。
|
||||
```
|
||||
|
||||
生成项目的 `internal/svc/databaseOptions.go` 是保留的项目扩展位置:
|
||||
|
||||
```go
|
||||
func (c *ServiceContext) DatabaseOptions() []mysqladapter.Option {
|
||||
return []mysqladapter.Option{
|
||||
mysqladapter.WithStartupCheck(func(ctx context.Context, db *database.DB) error {
|
||||
return checkProjectSchema(ctx, db)
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
检查在 Ping 后运行,共享启动时限;失败或 panic 会关闭新连接池。版本/表结构检查放在这些回调中,不放到框架。回调必须响应 context,并且不能自行关闭借用的 db。依赖尚未完成整体初始化时不要把连接池发布到外部。
|
||||
|
||||
`WithDriverConfig(func(*mysql.Config) error)` 支持高级拨号、驱动参数等,配置在回调后克隆;这是项目明确选择的底层扩展。`NewConnector` 返回标准 driver.Connector,供已有基础设施使用;它不接受启动检查选项。任意驱动仍可用核心 `database.OpenConnector`。
|
||||
|
||||
错误有安全摘要,errors.Is/As 保留 TLS/驱动/项目检查的原因。配置验证不读证书、不连接数据库;Open 才读取证书、创建池并握手。不要自行打印原始配置或展开可能包含内部信息的 driver 字段给用户。
|
||||
115
database/mysql/integration_test.go
Normal file
115
database/mysql/integration_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mysql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStructuredMySQLAdapterTLS(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
|
||||
t.Skip("use scripts/verifyConnector.py with an isolated local MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cfg := adapter.DefaultConfig()
|
||||
host, port, err := net.SplitHostPort(os.Getenv("GO_OMNI_CONNECTOR_ADDR"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Host = host
|
||||
cfg.Port, _ = strconv.Atoi(port)
|
||||
cfg.User = os.Getenv("GO_OMNI_CONNECTOR_USER")
|
||||
cfg.Password = os.Getenv("GO_OMNI_CONNECTOR_PASSWORD")
|
||||
cfg.Database = os.Getenv("GO_OMNI_CONNECTOR_DATABASE")
|
||||
cfg.TLS.CAFile = os.Getenv("GO_OMNI_CONNECTOR_CA_FILE")
|
||||
cfg.TLS.ServerName = "localhost"
|
||||
cfg.Pool.MaxOpenConns = 2
|
||||
cfg.Pool.MaxIdleConns = 2
|
||||
cfg.Pool.PingTimeout = 2 * time.Second
|
||||
cfg.Session = map[string]string{"time_zone": "'+00:00'"}
|
||||
checked := false
|
||||
first, err := adapter.Open(ctx, cfg, adapter.WithStartupCheck(func(ctx context.Context, db *database.DB) error {
|
||||
var one int
|
||||
err := db.QueryRowContext(ctx, "SELECT 1").Scan(&one)
|
||||
checked = one == 1
|
||||
return err
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
if !checked {
|
||||
t.Fatal("startup check not called")
|
||||
}
|
||||
cfg.Session = map[string]string{"time_zone": "'+08:00'"}
|
||||
second, err := adapter.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
for i, db := range []*database.DB{first, second} {
|
||||
var key, cipher, zone string
|
||||
if err = db.QueryRowContext(ctx, "SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&key, &cipher); err != nil || cipher == "" {
|
||||
t.Fatal("not encrypted", err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, "SELECT @@session.time_zone").Scan(&zone); err != nil || zone != []string{"+00:00", "+08:00"}[i] {
|
||||
t.Fatal("session isolation failed", zone, err)
|
||||
}
|
||||
if db.Stats().MaxOpenConnections != 2 {
|
||||
t.Fatal("pool limit ignored")
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"untrusted", "hostname"} {
|
||||
invalid := cfg
|
||||
if bad == "untrusted" {
|
||||
invalid.TLS.CAFile = ""
|
||||
} else {
|
||||
invalid.TLS.ServerName = "wrong.invalid"
|
||||
}
|
||||
db, e := adapter.Open(ctx, invalid)
|
||||
if db != nil {
|
||||
_ = db.Close()
|
||||
t.Fatal("bad certificate accepted")
|
||||
}
|
||||
var verify *tls.CertificateVerificationError
|
||||
if !errors.As(e, &verify) {
|
||||
t.Fatal("lost TLS verification cause", e)
|
||||
}
|
||||
}
|
||||
cause := errors.New("project schema check failed")
|
||||
if db, e := adapter.Open(ctx, cfg, adapter.WithStartupCheck(func(context.Context, *database.DB) error { return cause })); db != nil || !errors.Is(e, cause) {
|
||||
t.Fatal("failed startup escaped", e)
|
||||
}
|
||||
if err = first.Health(ctx); err != nil {
|
||||
t.Fatal("failed pool affected healthy pool", err)
|
||||
}
|
||||
// The server requires a client certificate for this separate fixture user.
|
||||
cfg.User = "omni_mtls"
|
||||
cfg.Password = ""
|
||||
cfg.Database = ""
|
||||
cfg.Session = nil
|
||||
if db, e := adapter.Open(ctx, cfg); e == nil {
|
||||
db.Close()
|
||||
t.Fatal("missing required client certificate accepted")
|
||||
}
|
||||
cfg.TLS.CertFile = os.Getenv("GO_OMNI_CONNECTOR_CLIENT_CERT")
|
||||
cfg.TLS.KeyFile = os.Getenv("GO_OMNI_CONNECTOR_CLIENT_KEY")
|
||||
mtls, err := adapter.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal("mTLS failed", err)
|
||||
}
|
||||
if err = mtls.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mtls.Close(); err != nil {
|
||||
t.Fatal("pool close is not idempotent", err)
|
||||
}
|
||||
}
|
||||
322
database/mysql/mysql.go
Normal file
322
database/mysql/mysql.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Package mysql provides an optional MySQL adapter over database.OpenConnector.
|
||||
// Each pool owns independent driver settings; no global driver registry changes
|
||||
// are made. It never migrates tables or retries application transactions.
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Socket string `json:"socket"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password" env:",secret"`
|
||||
Database string `json:"database"`
|
||||
ParseTime bool `json:"parseTime"`
|
||||
Location string `json:"location"`
|
||||
Charset string `json:"charset"`
|
||||
Collation string `json:"collation"`
|
||||
// Session values are SQL expressions, as required by the driver, for
|
||||
// example time_zone: "'+00:00'". They are trusted deployment configuration.
|
||||
Session map[string]string `json:"session"`
|
||||
ConnectTimeout time.Duration `json:"connectTimeout"`
|
||||
ReadTimeout time.Duration `json:"readTimeout"`
|
||||
WriteTimeout time.Duration `json:"writeTimeout"`
|
||||
MultiStatements bool `json:"multiStatements"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
Pool database.PoolConfig `json:"pool"`
|
||||
}
|
||||
type TLSConfig struct {
|
||||
Mode string `json:"mode"` // required (default) or disabled
|
||||
CAFile string `json:"caFile"`
|
||||
CertFile string `json:"certFile"`
|
||||
KeyFile string `json:"keyFile"`
|
||||
ServerName string `json:"serverName"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{Host: "127.0.0.1", Port: 3306, ParseTime: true, Location: "UTC", Charset: "utf8mb4", ConnectTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, TLS: TLSConfig{Mode: "required"}, Pool: database.DefaultPoolConfig()}
|
||||
}
|
||||
|
||||
var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// Validate does not read certificate files or open connections. All defaults
|
||||
// come from DefaultConfig; explicit zero I/O timeouts mean no driver deadline.
|
||||
func (c Config) Validate() error {
|
||||
if c.Socket == "" && (c.Host == "" || strings.ContainsAny(c.Host, "/\r\n\t ") || c.Port < 1 || c.Port > 65535) {
|
||||
return errors.New("mysql: invalid host or port")
|
||||
}
|
||||
if strings.ContainsRune(c.Socket, 0) || strings.ContainsRune(c.Database, 0) || strings.ContainsRune(c.User, 0) {
|
||||
return errors.New("mysql: invalid connection field")
|
||||
}
|
||||
if c.ConnectTimeout < 0 || c.ReadTimeout < 0 || c.WriteTimeout < 0 {
|
||||
return errors.New("mysql: timeouts cannot be negative")
|
||||
}
|
||||
if err := c.Pool.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Location != "" {
|
||||
if _, err := time.LoadLocation(c.Location); err != nil {
|
||||
return errors.New("mysql: invalid time location")
|
||||
}
|
||||
}
|
||||
if c.Charset != "" && !identifier.MatchString(c.Charset) {
|
||||
return errors.New("mysql: invalid charset")
|
||||
}
|
||||
if c.Collation != "" && !identifier.MatchString(c.Collation) {
|
||||
return errors.New("mysql: invalid collation")
|
||||
}
|
||||
for key := range c.Session {
|
||||
if !identifier.MatchString(key) {
|
||||
return errors.New("mysql: invalid session variable name")
|
||||
}
|
||||
}
|
||||
if c.TLS.Mode != "" && c.TLS.Mode != "required" && c.TLS.Mode != "disabled" {
|
||||
return errors.New("mysql: TLS mode must be required or disabled")
|
||||
}
|
||||
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
||||
return errors.New("mysql: TLS client certificate and key must be configured together")
|
||||
}
|
||||
if c.TLS.Mode == "disabled" && (c.TLS.CAFile != "" || c.TLS.CertFile != "" || c.TLS.ServerName != "") {
|
||||
return errors.New("mysql: TLS fields cannot be used when disabled")
|
||||
}
|
||||
if c.Socket != "" && c.TLS.Mode != "disabled" && c.TLS.ServerName == "" {
|
||||
return errors.New("mysql: TLS over a unix socket requires serverName")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Error has a safe summary; errors.Is/As retain driver/verification causes.
|
||||
// Do not log raw configuration or a driver's manually extracted error fields.
|
||||
type Error struct {
|
||||
Operation string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return "mysql: " + e.Operation + " failed" }
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
type Check func(context.Context, *database.DB) error
|
||||
type options struct {
|
||||
logger *logx.Logger
|
||||
checks []Check
|
||||
configure func(*mysqldriver.Config) error
|
||||
}
|
||||
type Option func(*options)
|
||||
|
||||
func WithLogger(l *logx.Logger) Option { return func(o *options) { o.logger = l } }
|
||||
|
||||
// WithStartupCheck runs after Ping, before ownership reaches the caller. A
|
||||
// failure closes the pool. Checks must honor ctx and must not close the pool.
|
||||
func WithStartupCheck(check Check) Option {
|
||||
return func(o *options) {
|
||||
if check != nil {
|
||||
o.checks = append(o.checks, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDriverConfig provides an advanced, per-pool escape hatch. Configuration
|
||||
// is cloned after this callback; keep credentials out of callback errors/logs.
|
||||
func WithDriverConfig(fn func(*mysqldriver.Config) error) Option {
|
||||
return func(o *options) { o.configure = fn }
|
||||
}
|
||||
|
||||
func settings(opts []Option) options {
|
||||
var o options
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&o)
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Open returns the existing Omni pool; .DB is the same standard sql.DB for
|
||||
// repositories or ORMs. Ping and startup checks share one bounded deadline.
|
||||
func Open(ctx context.Context, cfg Config, opts ...Option) (db *database.DB, err error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o := settings(opts)
|
||||
if o.logger == nil {
|
||||
o.logger = logx.FromContext(ctx)
|
||||
}
|
||||
connector, err := connector(cfg, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openPool(ctx, connector, cfg.Pool, o.checks)
|
||||
}
|
||||
|
||||
func openPool(ctx context.Context, connector driver.Connector, pool database.PoolConfig, checks []Check) (db *database.DB, err error) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, pool.PingTimeout)
|
||||
defer cancel()
|
||||
db, err = database.OpenConnector(checkCtx, connector, pool)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "connect", cause: err}
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
_ = db.Close()
|
||||
panic(p)
|
||||
}
|
||||
if err != nil {
|
||||
err = errors.Join(err, db.Close())
|
||||
db = nil
|
||||
}
|
||||
}()
|
||||
for _, check := range checks {
|
||||
if err = check(checkCtx, db); err != nil {
|
||||
return db, &Error{Operation: "startup check", cause: err}
|
||||
}
|
||||
if err = checkCtx.Err(); err != nil {
|
||||
return db, &Error{Operation: "startup check", cause: err}
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// NewConnector builds a standard connector for callers that own pool creation.
|
||||
// Startup checks are only executed by Open.
|
||||
func NewConnector(cfg Config, opts ...Option) (driver.Connector, error) {
|
||||
o := settings(opts)
|
||||
if len(o.checks) > 0 {
|
||||
return nil, errors.New("mysql: startup checks require Open, not NewConnector")
|
||||
}
|
||||
return connector(cfg, o)
|
||||
}
|
||||
func connector(cfg Config, o options) (driver.Connector, error) {
|
||||
d, err := driverConfig(cfg, o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := mysqldriver.NewConnector(d)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "connector configuration", cause: err}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func driverConfig(cfg Config, o options) (*mysqldriver.Config, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := mysqldriver.NewConfig()
|
||||
d.User, d.Passwd, d.DBName = cfg.User, cfg.Password, cfg.Database
|
||||
d.Net, d.Addr = "tcp", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||
if cfg.Socket != "" {
|
||||
d.Net, d.Addr = "unix", cfg.Socket
|
||||
}
|
||||
d.ParseTime = cfg.ParseTime
|
||||
if cfg.Location != "" {
|
||||
d.Loc, _ = time.LoadLocation(cfg.Location)
|
||||
}
|
||||
if cfg.Charset != "" {
|
||||
if err := d.Apply(mysqldriver.Charset(cfg.Charset, cfg.Collation)); err != nil {
|
||||
return nil, &Error{Operation: "charset", cause: err}
|
||||
}
|
||||
} else {
|
||||
d.Collation = cfg.Collation
|
||||
}
|
||||
d.Params = make(map[string]string, len(cfg.Session))
|
||||
for k, v := range cfg.Session {
|
||||
d.Params[k] = v
|
||||
}
|
||||
d.Timeout, d.ReadTimeout, d.WriteTimeout = cfg.ConnectTimeout, cfg.ReadTimeout, cfg.WriteTimeout
|
||||
d.MultiStatements = cfg.MultiStatements
|
||||
logger := o.logger
|
||||
if logger == nil {
|
||||
logger = logx.FromContext(nil)
|
||||
}
|
||||
d.Logger = driverLog{logger: logger, secret: cfg.Password}
|
||||
if cfg.TLS.Mode != "disabled" {
|
||||
name := cfg.TLS.ServerName
|
||||
if name == "" {
|
||||
name = cfg.Host
|
||||
}
|
||||
t := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: name}
|
||||
if cfg.TLS.CAFile != "" {
|
||||
pem, err := readCertificate(cfg.TLS.CAFile)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "read CA", cause: err}
|
||||
}
|
||||
t.RootCAs = x509.NewCertPool()
|
||||
if !t.RootCAs.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("mysql: invalid CA certificate")
|
||||
}
|
||||
}
|
||||
if cfg.TLS.CertFile != "" {
|
||||
cert, err := readCertificate(cfg.TLS.CertFile)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "read client certificate", cause: err}
|
||||
}
|
||||
key, err := readCertificate(cfg.TLS.KeyFile)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "read client key", cause: err}
|
||||
}
|
||||
pair, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, &Error{Operation: "client key pair", cause: err}
|
||||
}
|
||||
t.Certificates = []tls.Certificate{pair}
|
||||
}
|
||||
d.TLS = t
|
||||
}
|
||||
if o.configure != nil {
|
||||
if err := o.configure(d); err != nil {
|
||||
return nil, &Error{Operation: "custom configuration", cause: err}
|
||||
}
|
||||
}
|
||||
return d.Clone(), nil
|
||||
}
|
||||
|
||||
func readCertificate(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, (4<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > 4<<20 {
|
||||
return nil, errors.New("certificate file exceeds 4 MiB")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type driverLog struct {
|
||||
logger *logx.Logger
|
||||
secret string
|
||||
}
|
||||
|
||||
func (l driverLog) Print(values ...any) {
|
||||
message := fmt.Sprint(values...)
|
||||
if l.secret != "" {
|
||||
message = strings.ReplaceAll(message, l.secret, "[REDACTED]")
|
||||
}
|
||||
l.logger.Error("database.driver", "driver", "mysql", "error", message)
|
||||
}
|
||||
146
database/mysql/mysql_test.go
Normal file
146
database/mysql/mysql_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestConfigurationTLSLoggingAndIsolation(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.User = "test"
|
||||
cfg.Password = "do-not-print"
|
||||
cfg.Database = "mixed-name"
|
||||
cfg.Session = map[string]string{"time_zone": "'+00:00'"}
|
||||
cfg.Collation = "utf8mb4_bin"
|
||||
var out bytes.Buffer
|
||||
logger, _ := logx.New(logx.Config{Writer: &out})
|
||||
d, err := driverConfig(cfg, settings([]Option{WithLogger(logger)}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.TLS == nil || d.TLS.MinVersion != tls.VersionTLS12 || d.TLS.ServerName != cfg.Host || d.MultiStatements || d.Params["time_zone"] != "'+00:00'" {
|
||||
t.Fatal("driver policy differs")
|
||||
}
|
||||
cfg.Session["time_zone"] = "'+08:00'"
|
||||
if d.Params["time_zone"] != "'+00:00'" {
|
||||
t.Fatal("session map alias")
|
||||
}
|
||||
d.Logger.Print("connection ", cfg.Password)
|
||||
if strings.Contains(out.String(), cfg.Password) || !strings.Contains(out.String(), "[REDACTED]") {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
cfg.TLS.Mode = "disabled"
|
||||
d2, err := driverConfig(cfg, options{})
|
||||
if err != nil || d2.TLS != nil || d.TLS == nil {
|
||||
t.Fatal("TLS configuration crossed instances", err)
|
||||
}
|
||||
cfg.TLS.CAFile = "unused"
|
||||
if err = cfg.Validate(); err == nil {
|
||||
t.Fatal("disabled TLS accepted CA")
|
||||
}
|
||||
cfg = DefaultConfig()
|
||||
cfg.TLS.CAFile = filepath.Join(t.TempDir(), "missing")
|
||||
if err = cfg.Validate(); err != nil {
|
||||
t.Fatal("Validate reads certificates", err)
|
||||
}
|
||||
if _, err = NewConnector(cfg); err == nil {
|
||||
t.Fatal("missing CA accepted")
|
||||
}
|
||||
if err = os.WriteFile(cfg.TLS.CAFile, []byte("do-not-print"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = NewConnector(cfg); err == nil || strings.Contains(err.Error(), "do-not-print") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = NewConnector(DefaultConfig(), WithStartupCheck(func(context.Context, *database.DB) error { return nil })); err == nil {
|
||||
t.Fatal("unused startup check accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriverHandshakeErrorUsesIndependentLogger(t *testing.T) {
|
||||
var a, b bytes.Buffer
|
||||
la, _ := logx.New(logx.Config{Writer: &a})
|
||||
lb, _ := logx.New(logx.Config{Writer: &b})
|
||||
cfg := DefaultConfig()
|
||||
cfg.TLS.Mode = "disabled"
|
||||
cfg.Pool.PingTimeout = time.Second
|
||||
for i, l := range []*logx.Logger{la, lb} {
|
||||
before := []int{a.Len(), b.Len()}
|
||||
db, err := Open(context.Background(), cfg, WithLogger(l), WithDriverConfig(func(d *mysqldriver.Config) error {
|
||||
d.DialFunc = func(context.Context, string, string) (net.Conn, error) {
|
||||
reader, writer := net.Pipe()
|
||||
_ = writer.Close()
|
||||
return reader, nil
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
if db != nil || err == nil {
|
||||
t.Fatal("broken handshake succeeded")
|
||||
}
|
||||
sizes := []int{a.Len(), b.Len()}
|
||||
if sizes[i] <= before[i] || sizes[1-i] != before[1-i] {
|
||||
t.Fatal("driver log isolation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testConnector struct{ closed atomic.Int32 }
|
||||
|
||||
func (c *testConnector) Connect(context.Context) (driver.Conn, error) { return &testConn{c}, nil }
|
||||
func (c *testConnector) Driver() driver.Driver { return testDriver{} }
|
||||
|
||||
type testDriver struct{}
|
||||
|
||||
func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected DSN") }
|
||||
|
||||
type testConn struct{ owner *testConnector }
|
||||
|
||||
func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") }
|
||||
func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") }
|
||||
func (c *testConn) Ping(context.Context) error { return nil }
|
||||
func (c *testConn) Close() error { c.owner.closed.Add(1); return nil }
|
||||
|
||||
func TestStartupCheckRollbackAndPanic(t *testing.T) {
|
||||
for _, panics := range []bool{false, true} {
|
||||
c := &testConnector{}
|
||||
cause := errors.New("schema failed")
|
||||
func() {
|
||||
if panics {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("panic lost")
|
||||
}
|
||||
}()
|
||||
}
|
||||
db, err := openPool(context.Background(), c, database.DefaultPoolConfig(), []Check{func(ctx context.Context, db *database.DB) error {
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
t.Error("unbounded check")
|
||||
}
|
||||
if panics {
|
||||
panic("failed check")
|
||||
}
|
||||
return cause
|
||||
}})
|
||||
if db != nil || !errors.Is(err, cause) {
|
||||
t.Error("failed pool escaped or cause lost", err)
|
||||
}
|
||||
}()
|
||||
if c.closed.Load() != 1 {
|
||||
t.Fatal("failed initialization leaked pool", c.closed.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user