feat: add optional GORM pool and SQL logging adapter
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.14.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.14.1-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.0-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.14.1-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.14.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.14.1-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
|
|
||||||
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
新增通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||||
|
|
||||||
|
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
||||||
|
|
||||||
## 选择接入方式
|
## 选择接入方式
|
||||||
|
|
||||||
| 你的项目 | 从哪里开始 |
|
| 你的项目 | 从哪里开始 |
|
||||||
@@ -30,7 +32,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -186,7 +188,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -456,7 +458,7 @@ go test ./...
|
|||||||
|
|
||||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.14.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.14.1-preview`。v0.14.0 项目直接升级依赖与 CLI,GORM 按需导入;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
19
RELEASE.md
19
RELEASE.md
@@ -1,4 +1,21 @@
|
|||||||
# v0.14.0-preview
|
# v0.14.1-preview
|
||||||
|
|
||||||
|
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
||||||
|
|
||||||
|
在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。
|
||||||
|
|
||||||
|
v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过,详见 [GORM 验收](reports/gormAcceptance.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.14.0-preview(历史版本)
|
||||||
|
|
||||||
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
|
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -43,14 +43,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.14.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.14.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
新模板需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
模板最低需要 v0.14.0-preview 的通用 Runtime、配置和绑定能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
|
|||||||
@@ -242,8 +242,12 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||||
if requiredVersion != "" && requiredVersion != minimumVersion {
|
expectedVersion := minimumVersion
|
||||||
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: minimumVersion, Message: "project dependency differs from this CLI template version; use matching CLI/framework versions, or verify the explicit local replacement"})
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||||
|
expectedVersion = info.Main.Version
|
||||||
|
}
|
||||||
|
if requiredVersion != "" && requiredVersion != expectedVersion {
|
||||||
|
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"})
|
||||||
sortDiagnostics(out.Diagnostics)
|
sortDiagnostics(out.Diagnostics)
|
||||||
}
|
}
|
||||||
return out, err
|
return out, err
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.14.0-preview"
|
const defaultFrameworkVersion = "v0.14.1-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
|
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
|
||||||
|
|
||||||
|
使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。
|
||||||
|
|
||||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||||
|
|
||||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||||
|
|||||||
104
database/gormx/README.md
Normal file
104
database/gormx/README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 可选 GORM 适配器
|
||||||
|
|
||||||
|
从 `v0.14.1-preview` 提供。`gormx` 把 GORM 接到 go-omni 已初始化的 `*sql.DB`,保留一份连接池和一处资源所有权。使用 GORM v2 API(本版验收版本为 `gorm.io/gorm v1.31.2`、MySQL dialect `v1.6.0`)。
|
||||||
|
|
||||||
|
核心 `omni`、`database`、`database/mysql` 不导入此包;普通项目的编译依赖链不包含 GORM。仓库同一 Go module 的 go.mod 会记录可选适配器依赖,使用方显式导入 `database/gormx` 才编译它。本版没有新增默认 ORM 或 `--orm` 脚手架开关。
|
||||||
|
|
||||||
|
## 生成项目接入
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||||
|
go-omni new --database mysql --module example.com/billing ./billing
|
||||||
|
```
|
||||||
|
|
||||||
|
按 [MySQL 配置说明](../mysql/README.md) 设置连接信息、TLS 和池大小。在开发者维护的 `internal/svc/serviceContext.go` 中增加 `ORM *gorm.DB` 字段,并填写现有 Init:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 向现有 ServiceContext 添加 ORM *gorm.DB 字段。
|
||||||
|
// 保留 Runtime/Config/Logger/DB 等现有字段。
|
||||||
|
func (c *ServiceContext) Init(ctx context.Context) error {
|
||||||
|
orm, err := gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.ORM = orm
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
执行 `go mod tidy`。main、runtimeGen、Handler 和路由无需修改;重新生成保留 svc 的字段与 Init。已有项目先 `go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview`,同样接入;v0.14.0 生成项目的目录和生命周期契约不变。
|
||||||
|
|
||||||
|
Repository 接收 `*gorm.DB`,查询使用请求 context:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := repo.db.WithContext(ctx).Where("id = ?", id).First(&item).Error
|
||||||
|
|
||||||
|
err = repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 同一事务内始终使用 tx。
|
||||||
|
return tx.Create(&item).Error
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
现有模型、Hooks、Scopes、事务、命名策略和错误处理继续由项目维护。共享连接池不会把分别发起的 SQL/GORM 事务自动合并;不要在一个事务内切回根 ORM 或 c.DB 执行本应属于该事务的写入。
|
||||||
|
|
||||||
|
## 连接池与失败处理
|
||||||
|
|
||||||
|
- MySQL 配置、TLS、字符集、会话参数、池大小及健康检查由底层 `database/mysql` 管理,适配器不再设置另一套 DSN 或池参数。
|
||||||
|
- GORM 方言的版本协商继续执行,自动 Ping 关闭以复用已完成的池初始化。初始化查询使用传入 context,并受池的 PingTimeout 限制;可用 `WithInitTimeout(3*time.Second)` 单独指定。
|
||||||
|
- 返回成功后不保留启动 context,每次查询用 `WithContext(ctx)`。查询取消、错误、事务提交/回滚保持 GORM 原生语义。
|
||||||
|
- 对方言初始化失败时 GORM 自动关闭连接池的路径提供保护;错误仍保留 errors.Is/As,原池继续归调用方管理。生成项目的 Init 返回错误时,再由 Runtime 统一回滚该项目资源。
|
||||||
|
- 服务排空后由 go-omni 关闭原池;`orm.DB()` 返回的仍是那一个池。不要给它重复登记 Close,也不要让 Repository 关闭它。
|
||||||
|
|
||||||
|
适配器自身不创建后台 worker、不自动建表、不执行 AutoMigrate、不改写默认事务或全表更新保护,也不替业务重试写入。
|
||||||
|
|
||||||
|
## SQL 日志
|
||||||
|
|
||||||
|
默认借用当前 context 的 logx,也可显式 `WithLogger(c.Logger)`。每个 ORM 可以使用独立日志实例;不会改动进程全局 Logger。
|
||||||
|
|
||||||
|
```go
|
||||||
|
sqlLog := gormx.DefaultLogConfig()
|
||||||
|
sqlLog.Level = "info" // silent / error / warn / info
|
||||||
|
sqlLog.SlowThreshold = 300*time.Millisecond
|
||||||
|
|
||||||
|
orm, err := gormx.MySQL(ctx, c.DB,
|
||||||
|
gormx.WithLogger(c.Logger),
|
||||||
|
gormx.WithLogConfig(sqlLog),
|
||||||
|
gormx.WithConfig(gorm.Config{
|
||||||
|
TranslateError: true,
|
||||||
|
// NamingStrategy 等原生选项可按项目添加。
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
默认记录错误和超过 200 ms 的慢查询,忽略 ErrRecordNotFound 的错误事件;`SlowThreshold: 0` 关闭慢查询事件。事件为 `gorm.query`、`gorm.slowQuery`、`gorm.queryFailed`,携带 durationMs、rowsAffected、sql 和 context 中的 requestId/traceId。GORM 的 `Debug()`/`LogMode` 使用副本,不改变其他请求的日志级别;最终输出还受 logx 自身级别控制。
|
||||||
|
|
||||||
|
默认不插入绑定参数、不记录驱动完整错误文案,只记录错误类别;SQL 正文最多保留 4096 字节,截断后附加省略号。明确开启 `IncludeParameters` / `IncludeErrorDetails` 才输出这些内容。日志不会解析和脱敏直接写在原始 SQL 里的字面量,查询仍应使用占位符。仅需要日志适配的既有 GORM 实例,也可使用 `NewLogger(logger, config)` 返回的原生 `logger.Interface`。
|
||||||
|
|
||||||
|
`WithConfig` 中若显式设置 `gorm.Config.Logger`,则使用项目自己的 Logger,该 Logger 自行负责脱敏和上下文。配置和 Plugins/ClauseBuilders 容器会复制;用户自定义插件、NamingStrategy 等对象仍由用户保证并发安全。
|
||||||
|
|
||||||
|
## 其他方言与高级配置
|
||||||
|
|
||||||
|
MySQL 便捷入口封装了下面的通用入口:
|
||||||
|
|
||||||
|
```go
|
||||||
|
orm, err := gormx.Open(ctx, pool,
|
||||||
|
func(conn gorm.ConnPool) gorm.Dialector {
|
||||||
|
return mysql.New(mysql.Config{Conn: conn})
|
||||||
|
},
|
||||||
|
gormx.WithLogger(logger),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
其他方言可由项目提供相同形式的工厂,必须使用传入的 Conn,不能创建或替换连接池。本版真实数据库验收覆盖 MySQL 8.4,不代表其他数据库已经联调。
|
||||||
|
|
||||||
|
不能通过 WithConfig 的 ConnPool/Dialector 覆盖此约定。初始化期间不暴露裸 sql.DB;需要它的自定义插件应在 Open 成功后显式注册。创建额外池或替换 ConnPool 的路由插件不属于本适配器的一池所有权范围。
|
||||||
|
|
||||||
|
PrepareStmt 默认关闭;若显式开启,保留 GORM 的缓存行为,容量、TTL 和缓存清理由项目按 GORM 接口管理。当前固定的 GORM 版本,PreparedStmtDB.Close 只清理语句,不提供停止其缓存清理 goroutine 的公开接口;因此应在服务初始化时创建一次 ORM,避免按请求反复创建。适配器不修改第三方缓存实现,也不宣称解决其生命周期限制。
|
||||||
|
|
||||||
|
参考:[GORM 现有连接池](https://gorm.io/docs/connecting_to_the_database.html#Existing-database-connection)、[GORM Logger](https://gorm.io/docs/logger.html)。本版验收见 [GORM 验收报告](../../reports/gormAcceptance.md)。
|
||||||
121
database/gormx/gorm.go
Normal file
121
database/gormx/gorm.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Package gormx optionally attaches GORM to a pool owned by GoOmni. It never
|
||||||
|
// creates a pool, changes pool limits, migrates tables or closes the owner.
|
||||||
|
package gormx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
gormmysql "gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialectorFactory must use the supplied connection pool. It must not create a
|
||||||
|
// pool or retain/use the ORM before Open succeeds. For example, return
|
||||||
|
// mysql.New(mysql.Config{Conn: conn}). Other dialects can use the same contract.
|
||||||
|
type DialectorFactory func(conn gorm.ConnPool) gorm.Dialector
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
config gorm.Config
|
||||||
|
logger *logx.Logger
|
||||||
|
logConfig LogConfig
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
type Option func(*options)
|
||||||
|
|
||||||
|
// WithConfig preserves native GORM behavior, including naming, hooks, prepared
|
||||||
|
// statements and transactions. ConnPool/Dialector are supplied by this adapter;
|
||||||
|
// automatic Ping is disabled because the owning pool already passed startup Ping.
|
||||||
|
// A custom Config.Logger takes precedence over the logx adapter.
|
||||||
|
func WithConfig(config gorm.Config) Option { return func(o *options) { o.config = config } }
|
||||||
|
func WithLogger(logger *logx.Logger) Option { return func(o *options) { o.logger = logger } }
|
||||||
|
func WithLogConfig(config LogConfig) Option { return func(o *options) { o.logConfig = config } }
|
||||||
|
func WithInitTimeout(timeout time.Duration) Option { return func(o *options) { o.timeout = timeout } }
|
||||||
|
|
||||||
|
// Error exposes a safe initialization summary while preserving errors.Is/As.
|
||||||
|
type Error struct{ cause error }
|
||||||
|
|
||||||
|
func (e *Error) Error() string { return "gormx: initialization failed" }
|
||||||
|
func (e *Error) Unwrap() error { return e.cause }
|
||||||
|
|
||||||
|
// MySQL attaches GORM's MySQL dialect, including version negotiation, to the
|
||||||
|
// existing pool. Configure TLS, timeouts and connection limits on that pool.
|
||||||
|
func MySQL(ctx context.Context, pool *database.DB, opts ...Option) (*gorm.DB, error) {
|
||||||
|
return Open(ctx, pool, func(conn gorm.ConnPool) gorm.Dialector {
|
||||||
|
return gormmysql.New(gormmysql.Config{Conn: conn})
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open borrows pool. On failure (including dialect initialization failure),
|
||||||
|
// the original pool remains owned by the caller. No ORM Close is required.
|
||||||
|
// The returned ORM does not retain the startup context: each request should use
|
||||||
|
// WithContext(ctx). Never close the sql.DB returned by orm.DB() independently.
|
||||||
|
func Open(ctx context.Context, pool *database.DB, factory DialectorFactory, opts ...Option) (*gorm.DB, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, &Error{cause: err}
|
||||||
|
}
|
||||||
|
if pool == nil || pool.DB == nil || factory == nil {
|
||||||
|
return nil, errors.New("gormx: pool and dialector factory are required")
|
||||||
|
}
|
||||||
|
timeout := pool.PoolConfig().PingTimeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
o := options{timeout: timeout, logConfig: DefaultLogConfig(), logger: logx.FromContext(ctx)}
|
||||||
|
for _, option := range opts {
|
||||||
|
if option != nil {
|
||||||
|
option(&o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.timeout <= 0 {
|
||||||
|
return nil, errors.New("gormx: initialization timeout must be positive")
|
||||||
|
}
|
||||||
|
if o.config.ConnPool != nil || o.config.Dialector != nil {
|
||||||
|
return nil, errors.New("gormx: configure the existing pool through the dialector factory")
|
||||||
|
}
|
||||||
|
logger, err := NewLogger(o.logger, o.logConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
config := o.config
|
||||||
|
config.Plugins = maps.Clone(config.Plugins)
|
||||||
|
config.ClauseBuilders = maps.Clone(config.ClauseBuilders)
|
||||||
|
if config.Logger == nil {
|
||||||
|
config.Logger = logger
|
||||||
|
}
|
||||||
|
config.DisableAutomaticPing = true
|
||||||
|
startup, cancel := context.WithTimeout(ctx, o.timeout)
|
||||||
|
defer cancel()
|
||||||
|
borrowed := &borrowedPool{db: pool.DB}
|
||||||
|
borrowed.startup.Store(&startupContext{startup})
|
||||||
|
config.ConnPool = borrowed
|
||||||
|
dialect := factory(borrowed)
|
||||||
|
if dialect == nil || (reflect.ValueOf(dialect).Kind() == reflect.Pointer && reflect.ValueOf(dialect).IsNil()) {
|
||||||
|
return nil, errors.New("gormx: dialector factory returned nil")
|
||||||
|
}
|
||||||
|
orm, err := gorm.Open(dialect, &config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Error{cause: err}
|
||||||
|
}
|
||||||
|
if err = startup.Err(); err != nil {
|
||||||
|
return nil, &Error{cause: err}
|
||||||
|
}
|
||||||
|
// Prepared statements are GORM's own wrapper over the very same pool.
|
||||||
|
conn := orm.ConnPool
|
||||||
|
if prepared, ok := conn.(*gorm.PreparedStmtDB); ok {
|
||||||
|
conn = prepared.ConnPool
|
||||||
|
}
|
||||||
|
if actual, ok := conn.(*borrowedPool); !ok || actual != borrowed {
|
||||||
|
return nil, errors.New("gormx: dialector or plugin replaced the supplied pool")
|
||||||
|
}
|
||||||
|
borrowed.startup.Store(nil)
|
||||||
|
return orm, nil
|
||||||
|
}
|
||||||
173
database/gormx/gorm_test.go
Normal file
173
database/gormx/gorm_test.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package gormx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testConnector struct {
|
||||||
|
closed atomic.Int32
|
||||||
|
query func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testConnector) Connect(context.Context) (driver.Conn, error) {
|
||||||
|
return &testConn{owner: c}, nil
|
||||||
|
}
|
||||||
|
func (c *testConnector) Driver() driver.Driver { return testDriver{} }
|
||||||
|
func (c *testConnector) Close() error { c.closed.Add(1); return nil }
|
||||||
|
|
||||||
|
type testDriver struct{}
|
||||||
|
|
||||||
|
func (testDriver) Open(string) (driver.Conn, error) { return nil, errors.New("unexpected new pool") }
|
||||||
|
|
||||||
|
type testConn struct{ owner *testConnector }
|
||||||
|
|
||||||
|
func (c *testConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("unused") }
|
||||||
|
func (c *testConn) Close() error { return nil }
|
||||||
|
func (c *testConn) Begin() (driver.Tx, error) { return nil, errors.New("unused") }
|
||||||
|
func (c *testConn) Ping(context.Context) error { return nil }
|
||||||
|
func (c *testConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
if c.owner.query != nil {
|
||||||
|
if err := c.owner.query(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &testRows{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testRows struct{ done bool }
|
||||||
|
|
||||||
|
func (r *testRows) Columns() []string { return []string{"version"} }
|
||||||
|
func (r *testRows) Close() error { return nil }
|
||||||
|
func (r *testRows) Next(values []driver.Value) error {
|
||||||
|
if r.done {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
r.done = true
|
||||||
|
values[0] = "8.4.0"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func poolFixture(t *testing.T) (*database.DB, *testConnector) {
|
||||||
|
t.Helper()
|
||||||
|
connector := &testConnector{}
|
||||||
|
pool, err := database.OpenConnector(context.Background(), connector, database.DefaultPoolConfig())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
return pool, connector
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBorrowedPoolFailureAndStartupDeadline(t *testing.T) {
|
||||||
|
for _, mode := range []string{"driverError", "deadline", "canceled"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
pool, connector := poolFixture(t)
|
||||||
|
cause := errors.New("private-driver-error")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
connector.query = func(ctx context.Context) error {
|
||||||
|
if mode == "driverError" {
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if mode == "canceled" {
|
||||||
|
cancel()
|
||||||
|
cause = context.Canceled
|
||||||
|
} else if mode == "deadline" {
|
||||||
|
cause = context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
orm, err := MySQL(ctx, pool, WithInitTimeout(20*time.Millisecond))
|
||||||
|
if orm != nil || !errors.Is(err, cause) {
|
||||||
|
t.Fatal("lost cause", err)
|
||||||
|
}
|
||||||
|
if time.Since(start) > time.Second {
|
||||||
|
t.Fatal("startup deadline ignored")
|
||||||
|
}
|
||||||
|
if connector.closed.Load() != 0 {
|
||||||
|
t.Fatal("borrowed pool closed after GORM failure")
|
||||||
|
}
|
||||||
|
if healthErr := pool.Health(context.Background()); healthErr != nil {
|
||||||
|
t.Fatal("owner pool unusable", healthErr)
|
||||||
|
}
|
||||||
|
if err.Error() == cause.Error() {
|
||||||
|
t.Fatal("unsafe initialization error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolIdentityNativeOptionsAndRequestContext(t *testing.T) {
|
||||||
|
pool, connector := poolFixture(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cfg := gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "business_"}, ClauseBuilders: map[string]clause.ClauseBuilder{}}
|
||||||
|
logger, _ := logx.New(logx.Config{Output: "discard"})
|
||||||
|
orm, err := MySQL(ctx, pool, WithConfig(cfg), WithLogger(logger))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cancel() // The startup context must not poison future queries.
|
||||||
|
got, err := orm.DB()
|
||||||
|
if err != nil || got != pool.DB {
|
||||||
|
t.Fatal("pool identity changed", err)
|
||||||
|
}
|
||||||
|
if len(cfg.ClauseBuilders) != 0 || cfg.ConnPool != nil || cfg.Logger != nil {
|
||||||
|
t.Fatal("native input config mutated")
|
||||||
|
}
|
||||||
|
if orm.NamingStrategy.TableName("Item") != "business_items" {
|
||||||
|
t.Fatal("native option lost")
|
||||||
|
}
|
||||||
|
if orm.SkipDefaultTransaction || orm.AllowGlobalUpdate {
|
||||||
|
t.Fatal("transaction safety defaults changed")
|
||||||
|
}
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
var v string
|
||||||
|
if err = orm.WithContext(ctx).Raw("SELECT VERSION()").Scan(&v).Error; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = orm.WithContext(context.Background()).Raw("SELECT VERSION()").Scan(&v).Error; err != nil || v != "8.4.0" {
|
||||||
|
t.Fatal(v, err)
|
||||||
|
}
|
||||||
|
if connector.closed.Load() != 0 {
|
||||||
|
t.Fatal("pool unexpectedly closed")
|
||||||
|
}
|
||||||
|
if err = pool.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = orm.Raw("SELECT VERSION()").Scan(&v).Error; err == nil {
|
||||||
|
t.Fatal("owner close did not affect GORM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectedOptionsDoNotTouchPool(t *testing.T) {
|
||||||
|
for _, option := range []Option{WithInitTimeout(0), WithLogConfig(LogConfig{Level: "bad"}), WithConfig(gorm.Config{ConnPool: &borrowedPool{}})} {
|
||||||
|
pool, connector := poolFixture(t)
|
||||||
|
if _, err := MySQL(context.Background(), pool, option); err == nil {
|
||||||
|
t.Fatal("invalid option accepted")
|
||||||
|
}
|
||||||
|
if connector.closed.Load() != 0 {
|
||||||
|
t.Fatal("pool closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := MySQL(nil, nil); err == nil {
|
||||||
|
t.Fatal("nil pool accepted")
|
||||||
|
}
|
||||||
|
pool, _ := poolFixture(t)
|
||||||
|
if _, err := Open(nil, pool, func(gorm.ConnPool) gorm.Dialector { return nil }); err == nil {
|
||||||
|
t.Fatal("nil dialect accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
157
database/gormx/integration_test.go
Normal file
157
database/gormx/integration_test.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package gormx_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
|
||||||
|
adapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type record struct {
|
||||||
|
ID int64 `gorm:"primaryKey;autoIncrement:false"`
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (record) TableName() string { return "gorm_adapter_records" }
|
||||||
|
|
||||||
|
func TestGORMMySQLTransactionsCancellationAndLogs(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_CONNECTOR_MYSQL_TEST") != "1" {
|
||||||
|
t.Skip("use scripts/verifyConnector.py --gorm with isolated 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
|
||||||
|
pool, err := adapter.Open(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
var logs bytes.Buffer
|
||||||
|
logger, err := logx.New(logx.Config{Writer: &logs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
logConfig := gormx.DefaultLogConfig()
|
||||||
|
logConfig.Level = "info"
|
||||||
|
orm, err := gormx.MySQL(ctx, pool, gormx.WithLogger(logger), gormx.WithLogConfig(logConfig), gormx.WithConfig(gorm.Config{PrepareStmt: true, TranslateError: true}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlDB, err := orm.DB()
|
||||||
|
if err != nil || sqlDB != pool.DB {
|
||||||
|
t.Fatal("pool not shared", err)
|
||||||
|
}
|
||||||
|
if sqlDB.Stats().MaxOpenConnections != 2 {
|
||||||
|
t.Fatal("pool limits changed")
|
||||||
|
}
|
||||||
|
var tables int
|
||||||
|
if err = pool.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'gorm_adapter_records'").Scan(&tables); err != nil || tables != 0 {
|
||||||
|
t.Fatal("adapter must not migrate schema", err)
|
||||||
|
}
|
||||||
|
if _, err = pool.ExecContext(ctx, "CREATE TABLE gorm_adapter_records (id BIGINT PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pool.ExecContext(context.Background(), "DROP TABLE gorm_adapter_records")
|
||||||
|
requestCtx := logx.WithRequestID(ctx, "gorm-request")
|
||||||
|
orm = orm.WithContext(requestCtx)
|
||||||
|
secret := "private-bound-name"
|
||||||
|
if err = orm.Transaction(func(tx *gorm.DB) error { return tx.Create(&record{ID: 1, Name: secret}).Error }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wanted := errors.New("rollback sentinel")
|
||||||
|
if err = orm.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&record{ID: 2, Name: "rollback"}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return wanted
|
||||||
|
}); !errors.Is(err, wanted) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() != "rollback panic" {
|
||||||
|
t.Error("panic changed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = orm.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&record{ID: 3, Name: "panic"}).Error; e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
panic("rollback panic")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
var count int64
|
||||||
|
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 1 {
|
||||||
|
t.Fatal("commit/rollback differs", count, err)
|
||||||
|
}
|
||||||
|
if err = orm.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&record{ID: 4, Name: "outer"}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := tx.Transaction(func(inner *gorm.DB) error {
|
||||||
|
if e := inner.Create(&record{ID: 5, Name: "inner"}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return wanted
|
||||||
|
}); !errors.Is(e, wanted) {
|
||||||
|
return errors.New("nested rollback failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = orm.Model(&record{}).Count(&count).Error; err != nil || count != 2 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
if err = orm.Create(&record{ID: 6, Name: secret}).Error; !errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
t.Fatal("native error translation lost", err)
|
||||||
|
}
|
||||||
|
var missing record
|
||||||
|
if err = orm.First(&missing, "id = ?", 999).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
t.Fatal("not-found semantics changed", err)
|
||||||
|
}
|
||||||
|
deadline, stop := context.WithTimeout(requestCtx, 30*time.Millisecond)
|
||||||
|
start := time.Now()
|
||||||
|
err = orm.WithContext(deadline).Exec("SELECT SLEEP(3)").Error
|
||||||
|
stop()
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > time.Second {
|
||||||
|
t.Fatal("query cancellation lost", err)
|
||||||
|
}
|
||||||
|
if err = pool.Health(ctx); err != nil {
|
||||||
|
t.Fatal("pool did not recover", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(logs.String(), secret) || !strings.Contains(logs.String(), `"requestId":"gorm-request"`) || !strings.Contains(logs.String(), "gorm.queryFailed") {
|
||||||
|
t.Fatal("SQL log privacy/correlation differs")
|
||||||
|
}
|
||||||
|
var cipher struct {
|
||||||
|
VariableName string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
if err = orm.Raw("SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&cipher).Error; err != nil || cipher.Value == "" {
|
||||||
|
t.Fatal("GORM bypassed TLS pool", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
171
database/gormx/logger.go
Normal file
171
database/gormx/logger.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package gormx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
gormlog "gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `json:"level"` // silent, error, warn (default), info
|
||||||
|
SlowThreshold time.Duration `json:"slowThreshold"` // zero disables slow-query events
|
||||||
|
IgnoreRecordNotFound bool `json:"ignoreRecordNotFound"`
|
||||||
|
IncludeParameters bool `json:"includeParameters"`
|
||||||
|
IncludeErrorDetails bool `json:"includeErrorDetails"`
|
||||||
|
MaxSQLBytes int `json:"maxSqlBytes"` // zero uses 4096; bounds emitted SQL text
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLogConfig() LogConfig {
|
||||||
|
return LogConfig{Level: "warn", SlowThreshold: 200 * time.Millisecond, IgnoreRecordNotFound: true, MaxSQLBytes: 4096}
|
||||||
|
}
|
||||||
|
func (c LogConfig) Validate() error {
|
||||||
|
switch c.Level {
|
||||||
|
case "", "silent", "error", "warn", "info":
|
||||||
|
default:
|
||||||
|
return errors.New("gormx: invalid SQL log level")
|
||||||
|
}
|
||||||
|
if c.SlowThreshold < 0 || c.MaxSQLBytes < 0 {
|
||||||
|
return errors.New("gormx: SQL log limits cannot be negative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sqlLogger struct {
|
||||||
|
logger *logx.Logger
|
||||||
|
config LogConfig
|
||||||
|
level gormlog.LogLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger adapts logx without changing its level, globals or ownership.
|
||||||
|
// Bound SQL arguments and raw driver error text are omitted by default. SQL
|
||||||
|
// literals written directly into a query are not parsed or redacted.
|
||||||
|
func NewLogger(logger *logx.Logger, config LogConfig) (gormlog.Interface, error) {
|
||||||
|
if err := config.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if config.MaxSQLBytes == 0 {
|
||||||
|
config.MaxSQLBytes = 4096
|
||||||
|
}
|
||||||
|
level := gormlog.Warn
|
||||||
|
switch config.Level {
|
||||||
|
case "silent":
|
||||||
|
level = gormlog.Silent
|
||||||
|
case "error":
|
||||||
|
level = gormlog.Error
|
||||||
|
case "info":
|
||||||
|
level = gormlog.Info
|
||||||
|
}
|
||||||
|
return &sqlLogger{logger: logger, config: config, level: level}, nil
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) LogMode(level gormlog.LogLevel) gormlog.Interface {
|
||||||
|
copy := *l
|
||||||
|
if level < gormlog.Silent || level > gormlog.Info {
|
||||||
|
level = gormlog.Silent
|
||||||
|
}
|
||||||
|
copy.level = level
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) output(ctx context.Context) *logx.Logger {
|
||||||
|
if l.logger != nil {
|
||||||
|
return l.logger
|
||||||
|
}
|
||||||
|
return logx.FromContext(ctx)
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) message(msg string, args []any) string {
|
||||||
|
if !l.config.IncludeErrorDetails {
|
||||||
|
copy := append([]any(nil), args...)
|
||||||
|
for i, v := range copy {
|
||||||
|
if e, ok := v.(error); ok {
|
||||||
|
copy[i] = errorKind(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = copy
|
||||||
|
}
|
||||||
|
return clip(fmt.Sprintf(msg, args...), l.config.MaxSQLBytes)
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) Info(ctx context.Context, msg string, args ...any) {
|
||||||
|
if l.level >= gormlog.Info {
|
||||||
|
l.output(ctx).InfoContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) Warn(ctx context.Context, msg string, args ...any) {
|
||||||
|
if l.level >= gormlog.Warn {
|
||||||
|
l.output(ctx).WarnContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) Error(ctx context.Context, msg string, args ...any) {
|
||||||
|
if l.level >= gormlog.Error {
|
||||||
|
l.output(ctx).ErrorContext(ctx, "gorm.message", "message", l.message(msg, args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sqlLogger) ParamsFilter(_ context.Context, query string, args ...any) (string, []any) {
|
||||||
|
if !l.config.IncludeParameters {
|
||||||
|
return query, nil
|
||||||
|
}
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
func (l *sqlLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
if l.level == gormlog.Silent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed := time.Since(begin)
|
||||||
|
failure := err != nil && !(l.config.IgnoreRecordNotFound && errors.Is(err, gorm.ErrRecordNotFound))
|
||||||
|
slow := l.config.SlowThreshold > 0 && elapsed > l.config.SlowThreshold
|
||||||
|
level := gormlog.Info
|
||||||
|
if failure && l.level >= gormlog.Error {
|
||||||
|
level = gormlog.Error
|
||||||
|
} else if slow && l.level >= gormlog.Warn {
|
||||||
|
level = gormlog.Warn
|
||||||
|
} else if l.level < gormlog.Info {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query, rows := fc()
|
||||||
|
attrs := []any{"durationMs", float64(elapsed) / float64(time.Millisecond), "rowsAffected", rows, "sql", clip(query, l.config.MaxSQLBytes)}
|
||||||
|
if level == gormlog.Error {
|
||||||
|
attrs = append(attrs, "errorKind", errorKind(err))
|
||||||
|
if l.config.IncludeErrorDetails {
|
||||||
|
attrs = append(attrs, "error", clip(err.Error(), l.config.MaxSQLBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger := l.output(ctx)
|
||||||
|
switch level {
|
||||||
|
case gormlog.Error:
|
||||||
|
logger.ErrorContext(ctx, "gorm.queryFailed", attrs...)
|
||||||
|
case gormlog.Warn:
|
||||||
|
logger.WarnContext(ctx, "gorm.slowQuery", attrs...)
|
||||||
|
default:
|
||||||
|
logger.InfoContext(ctx, "gorm.query", attrs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func errorKind(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return "canceled"
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return "deadlineExceeded"
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
return "recordNotFound"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func clip(value string, limit int) string {
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
value = value[:limit]
|
||||||
|
for !utf8.ValidString(value) && len(value) > 0 {
|
||||||
|
value = value[:len(value)-1]
|
||||||
|
}
|
||||||
|
return value + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ gormlog.Interface = (*sqlLogger)(nil)
|
||||||
|
var _ gorm.ParamsFilter = (*sqlLogger)(nil)
|
||||||
92
database/gormx/logger_test.go
Normal file
92
database/gormx/logger_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package gormx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
gormlog "gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSQLLoggerLevelsPrivacyAndCorrelation(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
base, _ := logx.New(logx.Config{Writer: &out})
|
||||||
|
l, _ := NewLogger(base, DefaultLogConfig())
|
||||||
|
ctx := logx.WithRequestID(logx.WithTraceID(context.Background(), "trace-1"), "request-1")
|
||||||
|
count := 0
|
||||||
|
fc := func() (string, int64) { count++; return "SELECT * FROM records WHERE token = ?", 1 }
|
||||||
|
l.Trace(ctx, time.Now(), fc, nil)
|
||||||
|
l.Trace(ctx, time.Now(), fc, fmt.Errorf("wrapped: %w", gorm.ErrRecordNotFound))
|
||||||
|
l.LogMode(gormlog.Silent).Trace(ctx, time.Now(), fc, errors.New("password-value"))
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatal("ignored event evaluated SQL")
|
||||||
|
}
|
||||||
|
l.Trace(ctx, time.Now().Add(-time.Second), fc, nil)
|
||||||
|
l.Trace(ctx, time.Now(), fc, errors.New("password-value"))
|
||||||
|
l.Error(ctx, "failed: %v", errors.New("password-value"))
|
||||||
|
l.LogMode(gormlog.Info).Trace(ctx, time.Now(), fc, nil)
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatal(count)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.String(), "password-value") {
|
||||||
|
t.Fatal("driver detail leaked", out.String())
|
||||||
|
}
|
||||||
|
for _, text := range []string{"gorm.slowQuery", "gorm.queryFailed", "gorm.query", `"requestId":"request-1"`, `"traceId":"trace-1"`} {
|
||||||
|
if !strings.Contains(out.String(), text) {
|
||||||
|
t.Fatal(text, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q, args := l.(gorm.ParamsFilter).ParamsFilter(ctx, "SELECT ?", "private")
|
||||||
|
if q != "SELECT ?" || len(args) != 0 {
|
||||||
|
t.Fatal("default SQL arguments exposed")
|
||||||
|
}
|
||||||
|
// LogMode clones rather than mutating the shared instance.
|
||||||
|
l.Trace(ctx, time.Now(), fc, nil)
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatal("session mode changed root logger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLLoggerExplicitDetailsAndConcurrentModes(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
base, _ := logx.New(logx.Config{Writer: &out})
|
||||||
|
cfg := DefaultLogConfig()
|
||||||
|
cfg.IncludeParameters = true
|
||||||
|
cfg.IncludeErrorDetails = true
|
||||||
|
cfg.Level = "info"
|
||||||
|
cfg.MaxSQLBytes = 8
|
||||||
|
l, err := NewLogger(base, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, args := l.(gorm.ParamsFilter).ParamsFilter(context.Background(), "SELECT ?", "value")
|
||||||
|
if len(args) != 1 {
|
||||||
|
t.Fatal("explicit arguments dropped")
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
l.LogMode(gormlog.Error).Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 中文标识符", -1 }, errors.New("failure"))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
|
||||||
|
var event map[string]any
|
||||||
|
if err = json.Unmarshal([]byte(line), &event); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event["error"] != "failure" || strings.Contains(event["sql"].(string), "<22>") {
|
||||||
|
t.Fatal(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
database/gormx/pool.go
Normal file
52
database/gormx/pool.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package gormx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GORM can call DB().Close on failed initialization. Do not expose the owner's
|
||||||
|
// sql.DB until initialization has succeeded; SQL methods remain fully usable.
|
||||||
|
// Dialects using context.Background during version probing are bounded by the
|
||||||
|
// startup context, which stops affecting queries as soon as Open succeeds.
|
||||||
|
type borrowedPool struct {
|
||||||
|
db *sql.DB
|
||||||
|
startup atomic.Pointer[startupContext]
|
||||||
|
}
|
||||||
|
|
||||||
|
type startupContext struct{ context.Context }
|
||||||
|
|
||||||
|
func (p *borrowedPool) operationContext(ctx context.Context) context.Context {
|
||||||
|
if startup := p.startup.Load(); startup != nil {
|
||||||
|
return startup.Context
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) GetDBConn() (*sql.DB, error) {
|
||||||
|
if p.startup.Load() != nil {
|
||||||
|
return nil, gorm.ErrInvalidDB
|
||||||
|
}
|
||||||
|
return p.db, nil
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) {
|
||||||
|
return p.db.PrepareContext(p.operationContext(ctx), query)
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||||
|
return p.db.ExecContext(p.operationContext(ctx), query, args...)
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||||
|
return p.db.QueryContext(p.operationContext(ctx), query, args...)
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||||
|
return p.db.QueryRowContext(p.operationContext(ctx), query, args...)
|
||||||
|
}
|
||||||
|
func (p *borrowedPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
|
||||||
|
return p.db.BeginTx(p.operationContext(ctx), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ gorm.ConnPool = (*borrowedPool)(nil)
|
||||||
|
var _ gorm.TxBeginner = (*borrowedPool)(nil)
|
||||||
|
var _ gorm.GetDBConnector = (*borrowedPool)(nil)
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
|
本包可选,使用 `database/sql` 和 `go-sql-driver/mysql`,复用框架的唯一连接池、Ping、事务与健康检查。不自动建库/迁移、不重试写操作,也不强制某个 MySQL 版本或业务表。
|
||||||
|
|
||||||
|
项目使用 GORM 时,在 svc.Init 中调用 [gormx.MySQL](../gormx/README.md) 即可复用这个池、TLS 和清理流程。
|
||||||
|
|
||||||
生成项目选择 `--database mysql` 即接入:
|
生成项目选择 `--database mysql` 即接入:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -19,6 +19,8 @@ require (
|
|||||||
golang.org/x/net v0.33.0
|
golang.org/x/net v0.33.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,6 +38,8 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -67,6 +67,10 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
|||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
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/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -76,6 +80,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
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=
|
||||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA=
|
||||||
@@ -134,3 +140,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
|
|||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
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.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
36
reports/gormAcceptance.md
Normal file
36
reports/gormAcceptance.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# v0.14.1-preview GORM 可选适配验收
|
||||||
|
|
||||||
|
日期:2026-09-24。GORM v2 API,固定 `gorm.io/gorm v1.31.2`、`gorm.io/driver/mysql v1.6.0`。没有修改接入项目业务,也未使用业务数据库。
|
||||||
|
|
||||||
|
## 已通过的检查
|
||||||
|
|
||||||
|
- 原池身份与限额保持;初始化错误、已取消 context、版本查询超时都保留原因,且不会关闭借用池。
|
||||||
|
- 原生命名策略、默认事务保护、错误翻译、PreparedStmt 查询和嵌套事务保持可用。成功后启动 context 取消不会影响新请求。
|
||||||
|
- SQL 日志级别、慢查询、ErrRecordNotFound 忽略、占位符过滤、错误详情显式开关、requestId/traceId、并发 LogMode 副本与 UTF-8 截断。
|
||||||
|
- 真实 MySQL 8.4:TLS、同池连接、事务提交/错误回滚/panic 回滚、嵌套 savepoint 回滚、唯一约束、查询取消和后续恢复;参数与重复键错误中的值不进入默认日志。
|
||||||
|
- 实际生成 MySQL 项目,只扩展项目拥有的 svc 字段与 Init,main/运行期不改;生成检查通过,ORM 随项目池关闭。既有 MySQL TLS/mTLS 与 HTTP 限额回归一并执行。
|
||||||
|
- `go list -deps` 确认核心 App、database、database/mysql 的编译依赖不包含 GORM。
|
||||||
|
|
||||||
|
本地命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./... -count=1 -timeout=300s
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
python3 scripts/verifyConnector.py --local --gorm --report reports/gormLocal.json
|
||||||
|
```
|
||||||
|
|
||||||
|
[本地数据库报告](gormLocal.json) 的 MySQL 限制为 1 CPU、768 MiB、128 PIDs,只绑定 localhost;测试使用临时凭据/证书,结束后清理容器和消费者。实库用例通过脚本显式启用,不能把普通 go test 下的 skip 当作实库通过。
|
||||||
|
|
||||||
|
发布后的远程验收命令:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/verifyConnector.py --version v0.14.1-preview --gorm --report reports/gormRemote.json
|
||||||
|
python3 scripts/verifyManagedRelease.py --version v0.14.1-preview --report reports/gormManagedRemote.json
|
||||||
|
```
|
||||||
|
|
||||||
|
远程脚本从空缓存读取固定版本,并检查 CLI/消费者版本、module 校验值和没有本地 replace;结果发布后单独归档,代码标签保持不变。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
本版真实数据库只验收 MySQL。自定义方言工厂必须使用给定连接池;额外连接池由其所有者管理,不自动纳入当前池生命周期。GORM 默认不启用 PrepareStmt;显式启用时保留第三方缓存语义,其清理接口及后台 goroutine 的限制已在 [适配文档](../database/gormx/README.md) 说明。默认 SQL 参数隐藏不等于自动脱敏原始 SQL 字面量。
|
||||||
82
reports/gormLocal.json
Normal file
82
reports/gormLocal.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "v0.14.1-preview",
|
||||||
|
"localSource": true,
|
||||||
|
"gormAdapter": true,
|
||||||
|
"frameworkSum": null,
|
||||||
|
"freshModuleCache": false,
|
||||||
|
"noLocalReplace": false,
|
||||||
|
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||||
|
"mysqlLimits": {
|
||||||
|
"cpus": 1,
|
||||||
|
"memoryMiB": 768,
|
||||||
|
"pids": 128
|
||||||
|
},
|
||||||
|
"durationSeconds": 15.01,
|
||||||
|
"tests": [
|
||||||
|
"TestConnectorIndependentPoolsAndContext",
|
||||||
|
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||||
|
"TestConnectorStartupFailuresReleaseResources",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||||
|
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||||
|
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||||
|
"TestConnectorIdleZeroAndLifetime/expired",
|
||||||
|
"TestConnectorIdleZeroAndLifetime",
|
||||||
|
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||||
|
"TestMySQLConnectorDriverLoggerIsolation",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||||
|
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||||
|
"TestHTTPFileAndEnvironment/server.json",
|
||||||
|
"TestHTTPFileAndEnvironment/server.yaml",
|
||||||
|
"TestHTTPFileAndEnvironment",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||||
|
"TestHTTPInvalidAndUnsupportedSettings",
|
||||||
|
"TestHTTPConfiguredStandardServer",
|
||||||
|
"TestHTTPInvalidStartupReleasesResources",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||||
|
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||||
|
"TestStructuredMySQLAdapterTLS",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||||
|
"TestHTTPConfiguredNetworkLimits/native",
|
||||||
|
"TestHTTPConfiguredNetworkLimits",
|
||||||
|
"TestGeneratedOptionalGORM",
|
||||||
|
"TestGeneratedMySQLConfigurationAndLifecycle"
|
||||||
|
],
|
||||||
|
"sourceHashes": {
|
||||||
|
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||||
|
"database/mysqlConnector_test.go": "e1cdd11ef76b09f1332ba92bfdde5b1917d84c85cc42fac48f65a8b23f5e9720",
|
||||||
|
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||||
|
"config/http_test.go": "0cae3dcf213369cfaff5487bf630c2bc3ec097739ca1d40794124e69c3880db8",
|
||||||
|
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||||
|
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||||
|
"scripts/verifyConnector.py": "58d94e869e6faab1e49bd31ccaebab6d46b28e40058cf7805ad906057229149e",
|
||||||
|
"scripts/testdata/mysqlGenerated_test.go": "5630123add5ed17f261c1a1bf63ba773f7fa6ea613fd573d5c5cb72da3e42645",
|
||||||
|
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||||
|
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||||
|
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||||
|
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
scripts/testdata/gormGenerated_test.go
vendored
Normal file
48
scripts/testdata/gormGenerated_test.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"example.com/mysqlAcceptance/internal/config"
|
||||||
|
"example.com/mysqlAcceptance/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedOptionalGORM(t *testing.T) {
|
||||||
|
cfg, err := config.Load("etc/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c, err := svc.New(ctx, logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if c.ORM == nil {
|
||||||
|
t.Fatal("svc.Init did not provide GORM")
|
||||||
|
}
|
||||||
|
pool, err := c.ORM.DB()
|
||||||
|
if err != nil || pool != c.DB.DB {
|
||||||
|
t.Fatal("GORM created another pool", err)
|
||||||
|
}
|
||||||
|
var value int
|
||||||
|
if err = c.ORM.WithContext(ctx).Raw("SELECT ?", 42).Scan(&value).Error; err != nil || value != 42 {
|
||||||
|
t.Fatal(value, err)
|
||||||
|
}
|
||||||
|
if err = c.Finish(nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = c.ORM.WithContext(ctx).Raw("SELECT 1").Scan(&value).Error; err == nil {
|
||||||
|
t.Fatal("ORM did not follow owner shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ def main():
|
|||||||
source.add_argument("--version")
|
source.add_argument("--version")
|
||||||
parser.add_argument("--report", type=Path, required=True)
|
parser.add_argument("--report", type=Path, required=True)
|
||||||
parser.add_argument("--image", default="mysql:8.4")
|
parser.add_argument("--image", default="mysql:8.4")
|
||||||
|
parser.add_argument("--gorm", action="store_true", help="also verify the optional GORM adapter and generated svc extension")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
if args.version and not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
|
||||||
parser.error("a fixed release version is required")
|
parser.error("a fixed release version is required")
|
||||||
@@ -70,13 +71,16 @@ def main():
|
|||||||
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
||||||
project = temp / "consumer"
|
project = temp / "consumer"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
version = args.version or "v0.14.0-preview"
|
version = args.version or "v0.14.1-preview"
|
||||||
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
|
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
|
||||||
if args.local:
|
if args.local:
|
||||||
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
||||||
else:
|
else:
|
||||||
env["GOMODCACHE"] = str(temp / "modcache")
|
env["GOMODCACHE"] = str(temp / "modcache")
|
||||||
for name in SOURCES:
|
sources = list(SOURCES)
|
||||||
|
if args.gorm:
|
||||||
|
sources.append("database/gormx/integration_test.go")
|
||||||
|
for name in sources:
|
||||||
target = project / name
|
target = project / name
|
||||||
target.parent.mkdir(exist_ok=True, parents=True)
|
target.parent.mkdir(exist_ok=True, parents=True)
|
||||||
shutil.copyfile(ROOT / name, target)
|
shutil.copyfile(ROOT / name, target)
|
||||||
@@ -98,6 +102,18 @@ def main():
|
|||||||
command += ["--framework-dir", ROOT]
|
command += ["--framework-dir", ROOT]
|
||||||
run([*command, generated], temp, env)
|
run([*command, generated], temp, env)
|
||||||
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
|
shutil.copyfile(ROOT / "scripts/testdata/mysqlGenerated_test.go", generated / "mysql_test.go")
|
||||||
|
if args.gorm:
|
||||||
|
# Only the documented project-owned extension is customized.
|
||||||
|
service = generated / "internal/svc/serviceContext.go"
|
||||||
|
code = service.read_text()
|
||||||
|
assert '"context"' in code and 'type ServiceContext struct {' in code
|
||||||
|
code = code.replace('"context"', '"context"\n "gorm.io/gorm"\n "' + MODULE + '/database/gormx"', 1)
|
||||||
|
code = code.replace('type ServiceContext struct {', 'type ServiceContext struct {\n ORM *gorm.DB', 1)
|
||||||
|
assert 'return nil' in code
|
||||||
|
code = code.replace('return nil', 'var err error\n c.ORM, err = gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))\n return err', 1)
|
||||||
|
service.write_text(code)
|
||||||
|
run(["gofmt", "-w", service], generated, env)
|
||||||
|
shutil.copyfile(ROOT / "scripts/testdata/gormGenerated_test.go", generated / "gorm_test.go")
|
||||||
run(["go", "mod", "tidy"], generated, env)
|
run(["go", "mod", "tidy"], generated, env)
|
||||||
generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env))
|
generated_module = json.loads(run(["go", "list", "-m", "-json", MODULE], generated, env))
|
||||||
if not args.local:
|
if not args.local:
|
||||||
@@ -137,6 +153,8 @@ def main():
|
|||||||
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
||||||
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
|
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
|
||||||
assert "TestStructuredMySQLAdapterTLS" in passed
|
assert "TestStructuredMySQLAdapterTLS" in passed
|
||||||
|
if args.gorm:
|
||||||
|
assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed
|
||||||
assert not any(e.get("Action") in ("fail", "skip") for e in events), output
|
assert not any(e.get("Action") in ("fail", "skip") for e in events), output
|
||||||
env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1],
|
env.update(GO_OMNI_DB_HOST="127.0.0.1", GO_OMNI_DB_PORT=addr.rsplit(":", 1)[1],
|
||||||
GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"],
|
GO_OMNI_DB_USER="omni_test", GO_OMNI_DB_PASSWORD=env["MYSQL_PASSWORD"],
|
||||||
@@ -144,23 +162,30 @@ def main():
|
|||||||
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
|
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
|
||||||
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
|
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
|
||||||
print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
|
print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
|
||||||
run([cli, "api", "generate", "--check"], generated, env)
|
check_output = run([cli, "api", "generate", "--check"], generated, env)
|
||||||
|
if not args.local:
|
||||||
|
assert "frameworkVersion" not in check_output, check_output
|
||||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env)
|
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=60s", "./..."], generated, env)
|
||||||
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
generated_events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||||
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
|
generated_passed = [e["Test"] for e in generated_events if e.get("Action") == "pass" and "Test" in e]
|
||||||
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
|
assert "TestGeneratedMySQLConfigurationAndLifecycle" in generated_passed, output
|
||||||
|
if args.gorm:
|
||||||
|
assert "TestGeneratedOptionalGORM" in generated_passed, output
|
||||||
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
|
assert not any(e.get("Action") == "fail" or (e.get("Action") == "skip" and "Test" in e) for e in generated_events), output
|
||||||
passed += generated_passed
|
passed += generated_passed
|
||||||
image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip()
|
image = run(["docker", "inspect", "--format", "{{.Image}}", name], temp, env).strip()
|
||||||
finally:
|
finally:
|
||||||
if created:
|
if created:
|
||||||
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
||||||
report = {"passed": True, "frameworkVersion": version, "localSource": args.local,
|
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
|
||||||
|
if args.gorm:
|
||||||
|
report_sources += ["scripts/testdata/gormGenerated_test.go", "database/gormx/gorm.go", "database/gormx/pool.go", "database/gormx/logger.go"]
|
||||||
|
report = {"passed": True, "frameworkVersion": version, "localSource": args.local, "gormAdapter": args.gorm,
|
||||||
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
|
"frameworkSum": module.get("Sum"), "freshModuleCache": not args.local,
|
||||||
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
"noLocalReplace": not bool(module.get("Replace")), "mysqlImage": image,
|
||||||
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
"mysqlLimits": {"cpus": 1, "memoryMiB": 768, "pids": 128},
|
||||||
"durationSeconds": round(time.monotonic() - began, 2), "tests": passed,
|
"durationSeconds": round(time.monotonic() - began, 2), "tests": passed,
|
||||||
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in (*SOURCES, "scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go")}}
|
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in report_sources}}
|
||||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||||
print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)
|
print("PASS; isolated container, credentials, certificates and consumer removed", flush=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user