commit 83c727372ada0098ca0b648b9369f9e44203d0b0 Author: Yun Date: Sat Jun 6 02:09:22 2026 +0800 初始化实现 diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..e3c132a --- /dev/null +++ b/example/README.md @@ -0,0 +1,31 @@ +# mysqlx Examples + +本目录提供了 `mysqlx` SDK 的多种配置使用示例,按场景拆分到独立子目录: + +- `basic_dsn/`:通过原始 DSN 字符串创建客户端。 +- `expanded_dsn/`:通过 `WithDSNConfig` 及单个字段展开设置连接信息。 +- `custom_dialector/`:使用自定义 `gorm.Dialector`(例如 SQLite 内存数据库)创建客户端。 +- `advanced_options/`:演示 GORM 高级配置与连接池选项,如 `NamingStrategy`、`SkipDefaultTransaction`、`DisableForeignKeyConstraintWhenMigrating` 等。 + +## 运行示例 + +示例目录下每个文件可单独执行: + +```bash +cd c:/Code/pkg/mysqlx +go run ./example/basic_dsn/basic_dsn.go +``` + +```bash +go run ./example/expanded_dsn/expanded_dsn.go +``` + +```bash +go run ./example/custom_dialector/custom_dialector.go +``` + +```bash +go run ./example/advanced_options/gorm_advanced_options.go +``` + +也可以运行整个 `example` 目录里的单个子目录或文件。 diff --git a/example/advanced_options/gorm_advanced_options.go b/example/advanced_options/gorm_advanced_options.go new file mode 100644 index 0000000..ab69fd8 --- /dev/null +++ b/example/advanced_options/gorm_advanced_options.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "log" + "time" + + "code.yun.ink/pkg/mysqlx" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +func main() { + client, err := mysqlx.NewDB( + mysqlx.WithDSN("user:password@tcp(127.0.0.1:3306)/testdb?parseTime=true&loc=Local"), + mysqlx.WithLogger(logger.Default.LogMode(logger.Info)), + mysqlx.WithNamingStrategy(schema.NamingStrategy{SingularTable: true}), + mysqlx.WithDisableForeignKeyConstraintWhenMigrating(true), + mysqlx.WithSkipDefaultTransaction(true), + mysqlx.WithConnectionPool(5, 20, 15*time.Minute, time.Hour), + ) + if err != nil { + log.Fatalf("failed to create mysqlx client: %v", err) + } + defer client.Close() + + fmt.Println("MySQL client created with advanced GORM and connection pool options") + _ = client +} diff --git a/example/basic_dsn/basic_dsn.go b/example/basic_dsn/basic_dsn.go new file mode 100644 index 0000000..674ea5e --- /dev/null +++ b/example/basic_dsn/basic_dsn.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "log" + + "code.yun.ink/pkg/mysqlx" + "gorm.io/gorm/logger" +) + +func main() { + client, err := mysqlx.NewDB( + mysqlx.WithDSN("user:password@tcp(127.0.0.1:3306)/testdb?parseTime=true&loc=Local"), + mysqlx.WithLogger(logger.Default.LogMode(logger.Info)), + ) + if err != nil { + log.Fatalf("failed to create mysqlx client: %v", err) + } + defer client.Close() + + fmt.Println("MySQL client created with raw DSN") + _ = client +} diff --git a/example/custom_dialector/custom_dialector.go b/example/custom_dialector/custom_dialector.go new file mode 100644 index 0000000..a48bb56 --- /dev/null +++ b/example/custom_dialector/custom_dialector.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "log" + + "code.yun.ink/pkg/mysqlx" + "gorm.io/driver/sqlite" + "gorm.io/gorm/logger" +) + +func main() { + client, err := mysqlx.NewDB( + mysqlx.WithDialector(sqlite.Open("file::memory:?cache=shared")), + mysqlx.WithLogger(logger.Default.LogMode(logger.Info)), + ) + if err != nil { + log.Fatalf("failed to create mysqlx client with custom dialector: %v", err) + } + defer client.Close() + + fmt.Println("MySQL SDK client created with custom Dialector") + _ = client +} diff --git a/example/expanded_dsn/expanded_dsn.go b/example/expanded_dsn/expanded_dsn.go new file mode 100644 index 0000000..9cba439 --- /dev/null +++ b/example/expanded_dsn/expanded_dsn.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + "time" + + "code.yun.ink/pkg/mysqlx" + "github.com/go-sql-driver/mysql" + "gorm.io/gorm/logger" +) + +func main() { + cfg := mysql.NewConfig() + cfg.User = "testuser" + cfg.Passwd = "password" + cfg.Net = "tcp" + cfg.Addr = "127.0.0.1:3306" + cfg.DBName = "testdb" + cfg.ParseTime = true + cfg.Loc = time.Local + cfg.Params = map[string]string{ + "charset": "utf8mb4", + } + + client, err := mysqlx.NewDB( + mysqlx.WithDSNConfig(cfg), + mysqlx.WithDSNTimeout(5*time.Second), + mysqlx.WithDSNReadTimeout(10*time.Second), + mysqlx.WithDSNWriteTimeout(10*time.Second), + mysqlx.WithDSNParams(map[string]string{"multiStatements": "true"}), + mysqlx.WithLogger(logger.Default.LogMode(logger.Warn)), + ) + if err != nil { + log.Fatalf("failed to create mysqlx client: %v", err) + } + defer client.Close() + + fmt.Println("MySQL client created with expanded DSN configuration") + _ = client +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a4bb52d --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module code.yun.ink/pkg/mysqlx + +go 1.26.3 + +require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/go-sql-driver/mysql v1.8.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + golang.org/x/text v0.20.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6948860 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +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= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/mysqlx.go b/mysqlx.go new file mode 100644 index 0000000..faa2703 --- /dev/null +++ b/mysqlx.go @@ -0,0 +1,80 @@ +package mysqlx + +import ( + drivermysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// Client is the wrapper around a gorm DB instance. +type Client struct { + db *gorm.DB +} + +// New creates a Client using functional options. +// It requires either a DSN or a custom gorm Dialector. +func NewDB(opts ...Option) (*Client, error) { + cfg := defaultConfig() + for _, opt := range opts { + opt(cfg) + } + + if cfg.Dialector == nil { + dsn, err := buildDSN(cfg) + if err != nil { + return nil, err + } + cfg.Dialector = drivermysql.Open(dsn) + } + + gormConfig := &gorm.Config{ + Logger: cfg.Logger, + NamingStrategy: cfg.NamingStrategy, + DisableForeignKeyConstraintWhenMigrating: cfg.DisableForeignKeyConstraintWhenMigrating, + SkipDefaultTransaction: cfg.SkipDefaultTransaction, + DryRun: cfg.DryRun, + } + + db, err := gorm.Open(cfg.Dialector, gormConfig) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + if cfg.MaxIdleConns > 0 { + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + } + if cfg.MaxOpenConns > 0 { + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.ConnMaxIdleTime > 0 { + sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + } + if cfg.ConnMaxLifetime > 0 { + sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) + } + + return &Client{db: db}, nil +} + +// DB returns the underlying gorm DB. +func (c *Client) DB() *gorm.DB { + return c.db +} + +// Close closes the underlying sql.DB connection pool. +func (c *Client) Close() error { + sqlDB, err := c.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// AutoMigrate runs gorm AutoMigrate for the provided models. +func (c *Client) AutoMigrate(models ...interface{}) error { + return c.db.AutoMigrate(models...) +} diff --git a/mysqlx_test.go b/mysqlx_test.go new file mode 100644 index 0000000..c8055c4 --- /dev/null +++ b/mysqlx_test.go @@ -0,0 +1,62 @@ +package mysqlx + +import ( + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + drivermysql "gorm.io/driver/mysql" + "gorm.io/gorm/logger" +) + +type testModel struct { + ID uint `gorm:"primaryKey"` + Name string +} + +func TestNewWithCustomDialector(t *testing.T) { + sqlDB, _, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer sqlDB.Close() + + client, err := NewDB( + WithDialector(drivermysql.New(drivermysql.Config{Conn: sqlDB})), + WithLogger(logger.Default.LogMode(logger.Silent)), + WithConnectionPool(2, 5, 5*time.Minute, 10*time.Minute), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer client.Close() + + if client.DB() == nil { + t.Fatal("expected non-nil DB") + } +} + +func TestBuildDSNWithExpandedConfig(t *testing.T) { + cfg := defaultConfig() + WithDSNUser("testuser")(cfg) + WithDSNPassword("password")(cfg) + WithDSNNet("tcp")(cfg) + WithDSNAddr("127.0.0.1:3306")(cfg) + WithDSNDBName("testdb")(cfg) + WithDSNParams(map[string]string{"parseTime": "true", "loc": "Local"})(cfg) + WithDSNTLSConfig("false")(cfg) + dsn, err := buildDSN(cfg) + if err != nil { + t.Fatalf("buildDSN returned error: %v", err) + } + if dsn == "" { + t.Fatal("expected non-empty DSN") + } +} + +func TestNewWithoutDSNOrDialector(t *testing.T) { + _, err := NewDB() + if err == nil { + t.Fatal("expected error when DSN and Dialector are missing") + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..3d2eb84 --- /dev/null +++ b/options.go @@ -0,0 +1,340 @@ +package mysqlx + +import ( + "errors" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +// Option configures the SDK client. +type Option func(*config) + +type config struct { + DSN string + DSNConfig *mysqldriver.Config + DSNUser string + DSNPassword string + DSNNet string + DSNAddr string + DSNDBName string + DSNParams map[string]string + DSNParseTime *bool + DSNTLSConfig string + DSNLoc *time.Location + DSNCollation string + DSNTimeout time.Duration + DSNReadTimeout time.Duration + DSNWriteTimeout time.Duration + + Dialector gorm.Dialector + Logger logger.Interface + NamingStrategy schema.NamingStrategy + MaxIdleConns int + MaxOpenConns int + ConnMaxIdleTime time.Duration + ConnMaxLifetime time.Duration + DisableForeignKeyConstraintWhenMigrating bool + SkipDefaultTransaction bool + DryRun bool +} + +type DSN struct { + +} + +func defaultConfig() *config { + return &config{ + Logger: logger.Default, + NamingStrategy: schema.NamingStrategy{}, + MaxIdleConns: 10, + MaxOpenConns: 100, + } +} + +func buildDSN(cfg *config) (string, error) { + var mysqlConfig *mysqldriver.Config + if cfg.DSN != "" { + parsed, err := mysqldriver.ParseDSN(cfg.DSN) + if err != nil { + return "", err + } + mysqlConfig = parsed + } else if cfg.DSNConfig != nil { + mysqlConfig = cfg.DSNConfig.Clone() + } else { + mysqlConfig = mysqldriver.NewConfig() + } + + applyMySQLConfigOverrides(mysqlConfig, cfg) + + if mysqlConfig.User == "" && mysqlConfig.Passwd == "" && mysqlConfig.Addr == "" && mysqlConfig.DBName == "" && len(mysqlConfig.Params) == 0 { + if cfg.DSN == "" { + return "", errors.New("mysqlx: DSN or expanded DSN configuration must be provided") + } + } + + return mysqlConfig.FormatDSN(), nil +} + +func applyMySQLConfigOverrides(mysqlConfig *mysqldriver.Config, cfg *config) { + if cfg.DSNConfig != nil { + mergeMySQLConfig(mysqlConfig, cfg.DSNConfig) + } + if cfg.DSNUser != "" { + mysqlConfig.User = cfg.DSNUser + } + if cfg.DSNPassword != "" { + mysqlConfig.Passwd = cfg.DSNPassword + } + if cfg.DSNNet != "" { + mysqlConfig.Net = cfg.DSNNet + } + if cfg.DSNAddr != "" { + mysqlConfig.Addr = cfg.DSNAddr + } + if cfg.DSNDBName != "" { + mysqlConfig.DBName = cfg.DSNDBName + } + if cfg.DSNParams != nil { + if mysqlConfig.Params == nil { + mysqlConfig.Params = map[string]string{} + } + for key, value := range cfg.DSNParams { + mysqlConfig.Params[key] = value + } + } + if cfg.DSNParseTime != nil { + mysqlConfig.ParseTime = *cfg.DSNParseTime + } + if cfg.DSNTLSConfig != "" { + mysqlConfig.TLSConfig = cfg.DSNTLSConfig + } + if cfg.DSNLoc != nil { + mysqlConfig.Loc = cfg.DSNLoc + } + if cfg.DSNCollation != "" { + mysqlConfig.Collation = cfg.DSNCollation + } + if cfg.DSNTimeout > 0 { + mysqlConfig.Timeout = cfg.DSNTimeout + } + if cfg.DSNReadTimeout > 0 { + mysqlConfig.ReadTimeout = cfg.DSNReadTimeout + } + if cfg.DSNWriteTimeout > 0 { + mysqlConfig.WriteTimeout = cfg.DSNWriteTimeout + } +} + +func mergeMySQLConfig(base, override *mysqldriver.Config) { + if override.User != "" { + base.User = override.User + } + if override.Passwd != "" { + base.Passwd = override.Passwd + } + if override.Net != "" { + base.Net = override.Net + } + if override.Addr != "" { + base.Addr = override.Addr + } + if override.DBName != "" { + base.DBName = override.DBName + } + if override.Params != nil { + if base.Params == nil { + base.Params = map[string]string{} + } + for key, value := range override.Params { + base.Params[key] = value + } + } + if override.Collation != "" { + base.Collation = override.Collation + } + if override.Loc != nil { + base.Loc = override.Loc + } + if override.TLSConfig != "" { + base.TLSConfig = override.TLSConfig + } + if override.Timeout > 0 { + base.Timeout = override.Timeout + } + if override.ReadTimeout > 0 { + base.ReadTimeout = override.ReadTimeout + } + if override.WriteTimeout > 0 { + base.WriteTimeout = override.WriteTimeout + } + if override.ParseTime { + base.ParseTime = override.ParseTime + } +} + +// WithDSN sets the MySQL DSN used to open the database. +func WithDSN(dsn string) Option { + return func(cfg *config) { + cfg.DSN = dsn + } +} + +// WithDSNConfig sets the underlying mysql.Config used to generate the DSN. +func WithDSNConfig(mysqlConfig *mysqldriver.Config) Option { + return func(cfg *config) { + cfg.DSNConfig = mysqlConfig + } +} + +// WithDSNUser sets the MySQL username. +func WithDSNUser(user string) Option { + return func(cfg *config) { + cfg.DSNUser = user + } +} + +// WithDSNPassword sets the MySQL password. +func WithDSNPassword(password string) Option { + return func(cfg *config) { + cfg.DSNPassword = password + } +} + +// WithDSNNet sets the network type for the DSN. +func WithDSNNet(net string) Option { + return func(cfg *config) { + cfg.DSNNet = net + } +} + +// WithDSNAddr sets the database address for the DSN. +func WithDSNAddr(addr string) Option { + return func(cfg *config) { + cfg.DSNAddr = addr + } +} + +// WithDSNDBName sets the database name for the DSN. +func WithDSNDBName(dbName string) Option { + return func(cfg *config) { + cfg.DSNDBName = dbName + } +} + +// WithDSNParams sets additional DSN query parameters. +func WithDSNParams(params map[string]string) Option { + return func(cfg *config) { + if cfg.DSNParams == nil { + cfg.DSNParams = map[string]string{} + } + for key, value := range params { + cfg.DSNParams[key] = value + } + } +} + +// WithDSNParseTime configures parseTime for the DSN. +func WithDSNParseTime(parseTime bool) Option { + return func(cfg *config) { + cfg.DSNParseTime = &parseTime + } +} + +// WithDSNTLSConfig sets the TLS configuration name for the DSN. +func WithDSNTLSConfig(tlsConfig string) Option { + return func(cfg *config) { + cfg.DSNTLSConfig = tlsConfig + } +} + +// WithDSNLocation sets the time location for DSN parsing. +func WithDSNLocation(loc *time.Location) Option { + return func(cfg *config) { + cfg.DSNLoc = loc + } +} + +// WithDSNCollation sets the connection collation for the DSN. +func WithDSNCollation(collation string) Option { + return func(cfg *config) { + cfg.DSNCollation = collation + } +} + +// WithDSNTimeout sets the dial timeout for the DSN. +func WithDSNTimeout(timeout time.Duration) Option { + return func(cfg *config) { + cfg.DSNTimeout = timeout + } +} + +// WithDSNReadTimeout sets the read timeout for the DSN. +func WithDSNReadTimeout(timeout time.Duration) Option { + return func(cfg *config) { + cfg.DSNReadTimeout = timeout + } +} + +// WithDSNWriteTimeout sets the write timeout for the DSN. +func WithDSNWriteTimeout(timeout time.Duration) Option { + return func(cfg *config) { + cfg.DSNWriteTimeout = timeout + } +} + +// WithDialector sets a custom gorm Dialector. +func WithDialector(dialector gorm.Dialector) Option { + return func(cfg *config) { + cfg.Dialector = dialector + } +} + +// WithLogger sets the gorm logger. +func WithLogger(logger logger.Interface) Option { + return func(cfg *config) { + cfg.Logger = logger + } +} + +// WithNamingStrategy sets the naming strategy for gorm models. +func WithNamingStrategy(strategy schema.NamingStrategy) Option { + return func(cfg *config) { + cfg.NamingStrategy = strategy + } +} + +// WithConnectionPool configures database connection pooling. +func WithConnectionPool(maxIdleConns, maxOpenConns int, maxIdleTime, maxLifetime time.Duration) Option { + return func(cfg *config) { + cfg.MaxIdleConns = maxIdleConns + cfg.MaxOpenConns = maxOpenConns + cfg.ConnMaxIdleTime = maxIdleTime + cfg.ConnMaxLifetime = maxLifetime + } +} + +// WithDisableForeignKeyConstraintWhenMigrating toggles foreign key constraint creation during migrations. +func WithDisableForeignKeyConstraintWhenMigrating(disable bool) Option { + return func(cfg *config) { + cfg.DisableForeignKeyConstraintWhenMigrating = disable + } +} + +// WithSkipDefaultTransaction toggles default transactions in gorm. +func WithSkipDefaultTransaction(skip bool) Option { + return func(cfg *config) { + cfg.SkipDefaultTransaction = skip + } +} + +// WithDryRun toggles gorm dry run mode. +func WithDryRun(dryRun bool) Option { + return func(cfg *config) { + cfg.DryRun = dryRun + } +}