签入版本
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
// ============================== 数据库连接 ==============================
|
||||
|
||||
// openConn 按 DSN 动态建立连接并探活
|
||||
//
|
||||
// 通过 gdb.New 注册独立命名节点,不触碰 config.yaml 中的默认连接
|
||||
func openConn(ctx context.Context, node string, dsn DSN) (gdb.DB, error) {
|
||||
link, err := buildLink(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := gdb.New(gdb.ConfigNode{
|
||||
Name: node,
|
||||
Type: dsn.Type,
|
||||
Link: link,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 %s 库失败: %v", node, err)
|
||||
}
|
||||
if _, err = db.Query(ctx, "SELECT 1"); err != nil {
|
||||
return nil, fmt.Errorf("%s 库探活失败: %v", node, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ensureDatabase 目标库不存在时先创建
|
||||
//
|
||||
// MySQL、PostgreSQL、SQL Server 经维护库建库;Oracle 与 SQLite 无需预建
|
||||
func ensureDatabase(ctx context.Context, dsn DSN) error {
|
||||
var maintainDB, createSQL, checkSQL string
|
||||
switch dsn.Type {
|
||||
case "mysql":
|
||||
maintainDB, checkSQL = "", "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?"
|
||||
createSQL = "CREATE DATABASE `" + dsn.Database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
|
||||
case "pgsql":
|
||||
maintainDB, checkSQL = "postgres", "SELECT 1 FROM pg_database WHERE datname = ?"
|
||||
createSQL = `CREATE DATABASE "` + dsn.Database + `"`
|
||||
case "mssql":
|
||||
maintainDB, checkSQL = "master", "SELECT 1 FROM sys.databases WHERE name = ?"
|
||||
createSQL = "CREATE DATABASE [" + dsn.Database + "]"
|
||||
default:
|
||||
return nil // oracle/sqlite 无需预建
|
||||
}
|
||||
checkDSN := dsn
|
||||
checkDSN.Database = maintainDB
|
||||
link, err := buildLink(checkDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maintain, err := gdb.New(gdb.ConfigNode{Name: "migrate_maintain", Type: dsn.Type, Link: link})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接 %s 维护库失败: %v", dsn.Type, err)
|
||||
}
|
||||
defer maintain.Close(ctx)
|
||||
row, err := maintain.Query(ctx, checkSQL, dsn.Database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查目标库是否存在失败: %v", err)
|
||||
}
|
||||
if len(row) > 0 {
|
||||
return nil // 已存在
|
||||
}
|
||||
if _, err = maintain.Exec(ctx, createSQL); err != nil {
|
||||
return fmt.Errorf("创建目标库 %s 失败: %v", dsn.Database, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLink 将 DSN 转换为 gdb link 串
|
||||
//
|
||||
// 各方言按驱动约定格式组装,对齐配置示例
|
||||
func buildLink(dsn DSN) (string, error) {
|
||||
switch dsn.Type {
|
||||
case "mysql":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=true&loc=Local",
|
||||
dsn.Username, dsn.Password, dsn.Host, dsn.Port, dsn.Database), nil
|
||||
case "pgsql":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?sslmode=disable",
|
||||
dsn.Username, dsn.Password, dsn.Host, dsn.Port, dsn.Database), nil
|
||||
case "mssql":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?encrypt=disable",
|
||||
dsn.Username, dsn.Password, dsn.Host, dsn.Port, dsn.Database), nil
|
||||
case "oracle":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
|
||||
dsn.Username, dsn.Password, dsn.Host, dsn.Port, dsn.Database), nil
|
||||
case "sqlite":
|
||||
if dsn.Database == "" {
|
||||
return "", fmt.Errorf("sqlite 需以 -src-database/-dst-database 指定数据库文件路径")
|
||||
}
|
||||
return fmt.Sprintf("sqlite::@file(%s)", dsn.Database), nil
|
||||
}
|
||||
return "", fmt.Errorf("不支持的数据库类型: %s", dsn.Type)
|
||||
}
|
||||
|
||||
// tableExists 判断目标库中同名表是否已存在
|
||||
func tableExists(ctx context.Context, db gdb.DB, table string) (bool, error) {
|
||||
tables, err := db.Tables(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if t == table {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
// ============================== 数据搬运 ==============================
|
||||
|
||||
// copyTable 分批搬运单表数据
|
||||
//
|
||||
// 源端按主键排序做快照分页,目标端使用多行 INSERT。
|
||||
// onBatch 为每批完成回调(本批行数、累计行数),为 nil 时静默。
|
||||
// 返回搬运行数;无主键表直接报错。
|
||||
// SQL Server 目标使用内联字面量 INSERT 绕过参数上限,含自增列时以事务包裹切换 IDENTITY_INSERT。
|
||||
func copyTable(ctx context.Context, src, dst gdb.DB, r Renderer, t *TableMeta, batchSize int, onBatch func(batch, total int64)) (int64, error) {
|
||||
pk := pkColumns(t)
|
||||
if len(pk) == 0 {
|
||||
return 0, errNoPK(t.Name)
|
||||
}
|
||||
pkCol := pk[0]
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1000
|
||||
}
|
||||
|
||||
// 目标端列清单(以源表列序组装 INSERT,map 键名驱动目标方言语句生成)
|
||||
colNames := make([]string, 0, len(t.Columns))
|
||||
for _, c := range t.Columns {
|
||||
colNames = append(colNames, c.Name)
|
||||
}
|
||||
|
||||
// mssql:内联字面量 INSERT(无参数占位符,不受 2100 上限约束);自增列事务钉连接
|
||||
if dst.GetConfig().Type == "mssql" {
|
||||
return copyMssql(ctx, src, dst, r, t, pkCol, colNames, batchSize, onBatch)
|
||||
}
|
||||
|
||||
// 其余方言:参数化多行 INSERT
|
||||
return doCopy(ctx, src, dst.Ctx(ctx), t.Name, pkCol, colNames, batchSize, onBatch,
|
||||
func(ins inserter, batch []map[string]any) error {
|
||||
_, err := ins.Model(t.Name).Data(batch).Insert()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// copyMssql SQL Server 数据搬运
|
||||
//
|
||||
// 使用内联字面量 INSERT 绕过参数上限;
|
||||
// 自增列每批独立小事务切换 IDENTITY_INSERT,
|
||||
// 既钉住连接,又避免整表单事务撑爆事务日志
|
||||
func copyMssql(ctx context.Context, src, dst gdb.DB, r Renderer, t *TableMeta, pkCol string, colNames []string, batchSize int, onBatch func(batch, total int64)) (int64, error) {
|
||||
quoted := r.Quote(t.Name)
|
||||
|
||||
// 自增列:每批 SET ON → INSERT → SET OFF → COMMIT(事务粒度=单批,日志占用受批大小约束)
|
||||
if hasAutoIncCol(t) {
|
||||
return doCopy(ctx, src, dst.Ctx(ctx), t.Name, pkCol, colNames, batchSize, onBatch,
|
||||
func(ins inserter, batch []map[string]any) error {
|
||||
tx, err := dst.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec("SET IDENTITY_INSERT " + quoted + " ON"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(buildInsertSQL(r, t.Name, colNames, batch)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("SET IDENTITY_INSERT " + quoted + " OFF"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
})
|
||||
}
|
||||
|
||||
return doCopy(ctx, src, dst.Ctx(ctx), t.Name, pkCol, colNames, batchSize, onBatch,
|
||||
func(ins inserter, batch []map[string]any) error {
|
||||
_, err := dst.Exec(ctx, buildInsertSQL(r, t.Name, colNames, batch))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// inserter 目标端写入器
|
||||
// gdb.DB 与 gdb.TX 均满足该接口
|
||||
type inserter interface {
|
||||
Model(tableNameOrStruct ...any) *gdb.Model
|
||||
}
|
||||
|
||||
// doCopy 核心搬运循环
|
||||
//
|
||||
// insertBatch 为每批写入实现,方言差异由此注入
|
||||
func doCopy(ctx context.Context, src gdb.DB, ins inserter, table, pkCol string, colNames []string, batchSize int, onBatch func(batch, total int64), insertBatch func(ins inserter, batch []map[string]any) error) (int64, error) {
|
||||
var (
|
||||
total int64
|
||||
lastPK any
|
||||
)
|
||||
for {
|
||||
m := src.Ctx(ctx).Model(table).Fields(strings.Join(quoteAll(src, colNames), ",")).Order(pkCol).Limit(batchSize)
|
||||
if lastPK != nil {
|
||||
m = m.Where(pkCol+" > ?", lastPK)
|
||||
}
|
||||
rows, err := m.All()
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return total, nil
|
||||
}
|
||||
batch := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := make(map[string]any, len(colNames))
|
||||
for _, c := range colNames {
|
||||
item[c] = convertValue(row[c])
|
||||
}
|
||||
batch = append(batch, item)
|
||||
lastPK = row[pkCol].Val()
|
||||
}
|
||||
if err = insertBatch(ins, batch); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += int64(len(batch))
|
||||
if onBatch != nil {
|
||||
onBatch(int64(len(batch)), total)
|
||||
}
|
||||
if len(rows) < batchSize {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildInsertSQL 构建带内联字面量的多行 INSERT
|
||||
//
|
||||
// SQL Server 专用,绕过 2100 参数占位符上限
|
||||
func buildInsertSQL(r Renderer, table string, colNames []string, batch []map[string]any) string {
|
||||
cols := make([]string, len(colNames))
|
||||
for i, c := range colNames {
|
||||
cols[i] = r.Quote(c)
|
||||
}
|
||||
valueRows := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
vals := make([]string, len(colNames))
|
||||
for i, c := range colNames {
|
||||
vals[i] = inlineValue(row[c])
|
||||
}
|
||||
valueRows = append(valueRows, "("+strings.Join(vals, ",")+")")
|
||||
}
|
||||
return "INSERT INTO " + r.Quote(table) + " (" + strings.Join(cols, ",") + ") VALUES " + strings.Join(valueRows, ",")
|
||||
}
|
||||
|
||||
// inlineValue 将值转换为内联字面量
|
||||
//
|
||||
// nil 转 NULL,字符串与字节切片转义单引号,
|
||||
// 数值与布尔原样,时间格式化输出
|
||||
func inlineValue(v any) string {
|
||||
if v == nil {
|
||||
return "NULL"
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return "'" + strings.ReplaceAll(t, "'", "''") + "'"
|
||||
case []byte:
|
||||
if len(t) == 0 {
|
||||
return "NULL"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(string(t), "'", "''") + "'"
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprintf("%v", t)
|
||||
case bool:
|
||||
if t {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case time.Time:
|
||||
return "'" + t.Format("2006-01-02 15:04:05") + "'"
|
||||
default:
|
||||
return "'" + strings.ReplaceAll(fmt.Sprintf("%v", t), "'", "''") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
// quoteAll 源端标识符引用
|
||||
//
|
||||
// 源方言与目标方言可能不同,按源方言的引号规则处理
|
||||
func quoteAll(db gdb.DB, cols []string) []string {
|
||||
out := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
switch db.GetConfig().Type {
|
||||
case "mysql":
|
||||
out[i] = "`" + c + "`"
|
||||
case "mssql":
|
||||
out[i] = "[" + c + "]"
|
||||
default:
|
||||
out[i] = `"` + c + `"`
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// convertValue 值转换
|
||||
//
|
||||
// 字节切片文本转为字符串,便于目标驱动绑定,其余原样返回
|
||||
func convertValue(v gdb.Value) any {
|
||||
val := v.Val()
|
||||
if b, ok := val.([]byte); ok {
|
||||
return string(b)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// errNoPK 无主键表错误
|
||||
func errNoPK(table string) error {
|
||||
return &migrateError{"表 " + table + " 无主键,无法分页搬运(请先补主键)"}
|
||||
}
|
||||
|
||||
// migrateError 迁移错误类型
|
||||
type migrateError struct{ msg string }
|
||||
|
||||
// Error 返回错误信息
|
||||
func (e *migrateError) Error() string { return e.msg }
|
||||
@@ -0,0 +1,846 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
// ============================== 目标方言 DDL 渲染 ==============================
|
||||
|
||||
// Renderer 目标方言 DDL 渲染器
|
||||
// 负责标识符引用、列定义、建表、建索引、清表与自增序列修正
|
||||
type Renderer interface {
|
||||
Quote(name string) string
|
||||
Column(col ColumnMeta) string
|
||||
CreateTable(t *TableMeta) string
|
||||
CreateIndex(t *TableMeta, idx IndexMeta) string
|
||||
DropAllTables(ctx context.Context, db gdb.DB) error
|
||||
FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error
|
||||
}
|
||||
|
||||
// NewRenderer 按目标方言创建渲染器
|
||||
func NewRenderer(dialect string) (Renderer, error) {
|
||||
switch dialect {
|
||||
case "mysql":
|
||||
return &mysqlRenderer{dialect: "mysql"}, nil
|
||||
case "pgsql":
|
||||
return &pgsqlRenderer{dialect: "pgsql"}, nil
|
||||
case "sqlite":
|
||||
return &sqliteRenderer{dialect: "sqlite"}, nil
|
||||
case "mssql":
|
||||
return &mssqlRenderer{dialect: "mssql"}, nil
|
||||
case "oracle":
|
||||
return &oracleRenderer{dialect: "oracle"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("不支持的目标数据库类型: %s", dialect)
|
||||
}
|
||||
|
||||
// quoteStd 双引号引用
|
||||
// 适用于 PostgreSQL、Oracle、SQLite
|
||||
func quoteStd(name string) string {
|
||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// quoteTick 反引号引用
|
||||
// 适用于 MySQL
|
||||
func quoteTick(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
// quoteBracket 方括号引用
|
||||
// 适用于 SQL Server
|
||||
func quoteBracket(name string) string {
|
||||
return "[" + strings.ReplaceAll(name, "]", "]]") + "]"
|
||||
}
|
||||
|
||||
// defaultLiteral 渲染默认值字面量
|
||||
//
|
||||
// 数值原样输出,其余加引号;
|
||||
// 时间函数按目标方言归一转换
|
||||
func defaultLiteral(dialect, v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
u := strings.ToUpper(v)
|
||||
// 时间函数按目标方言归一(NOW/CURRENT_TIMESTAMP → 各库原生)
|
||||
if u == "CURRENT_TIMESTAMP" || u == "NOW()" || u == "NOW" {
|
||||
switch dialect {
|
||||
case "mssql":
|
||||
return "GETDATE()"
|
||||
case "oracle":
|
||||
return "SYSDATE"
|
||||
default:
|
||||
return "CURRENT_TIMESTAMP"
|
||||
}
|
||||
}
|
||||
if u == "NULL" {
|
||||
return "NULL"
|
||||
}
|
||||
// 纯数值/负数原样
|
||||
if _, err := fmt.Sscanf(v, "%f", new(float64)); err == nil {
|
||||
return v
|
||||
}
|
||||
return "'" + strings.ReplaceAll(v, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// hasAutoIncCol 判断表中是否存在自增列
|
||||
func hasAutoIncCol(t *TableMeta) bool {
|
||||
for _, c := range t.Columns {
|
||||
if c.AutoInc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pkColumns 返回表的主键列清单
|
||||
func pkColumns(t *TableMeta) []string {
|
||||
var cols []string
|
||||
for _, c := range t.Columns {
|
||||
if c.IsPK {
|
||||
cols = append(cols, c.Name)
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MySQL
|
||||
// ============================================================
|
||||
|
||||
// mysqlRenderer MySQL 方言渲染器
|
||||
type mysqlRenderer struct{ dialect string }
|
||||
|
||||
// Quote 引用标识符
|
||||
func (r *mysqlRenderer) Quote(name string) string { return quoteTick(name) }
|
||||
|
||||
// Column 渲染列定义
|
||||
func (r *mysqlRenderer) Column(col ColumnMeta) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(quoteTick(col.Name) + " " + r.colType(col))
|
||||
if col.AutoInc {
|
||||
b.WriteString(" NOT NULL AUTO_INCREMENT")
|
||||
} else {
|
||||
if !col.Nullable {
|
||||
b.WriteString(" NOT NULL")
|
||||
}
|
||||
if d := defaultLiteral(r.dialect, col.Default); d != "" {
|
||||
b.WriteString(" DEFAULT " + d)
|
||||
}
|
||||
}
|
||||
if col.Comment != "" {
|
||||
b.WriteString(" COMMENT '" + strings.ReplaceAll(col.Comment, "'", "''") + "'")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colType 列类型映射
|
||||
func (r *mysqlRenderer) colType(col ColumnMeta) string {
|
||||
switch col.Type {
|
||||
case TypeBool:
|
||||
return "tinyint(1)"
|
||||
case TypeTinyint:
|
||||
return "tinyint"
|
||||
case TypeSmallint:
|
||||
return "smallint"
|
||||
case TypeInt:
|
||||
return "int"
|
||||
case TypeBigint:
|
||||
return "bigint"
|
||||
case TypeFloat:
|
||||
return "float"
|
||||
case TypeDouble:
|
||||
return "double"
|
||||
case TypeDecimal:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("decimal(%d,%d)", col.Length, col.Scale)
|
||||
}
|
||||
return "decimal"
|
||||
case TypeVarchar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("varchar(%d)", col.Length)
|
||||
}
|
||||
return "varchar(255)"
|
||||
case TypeChar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("char(%d)", col.Length)
|
||||
}
|
||||
return "char(36)"
|
||||
case TypeTinytext:
|
||||
return "tinytext"
|
||||
case TypeMediumText:
|
||||
return "mediumtext"
|
||||
case TypeLongText:
|
||||
return "longtext"
|
||||
case TypeText:
|
||||
return "text"
|
||||
case TypeDate:
|
||||
return "date"
|
||||
case TypeTime:
|
||||
return "time"
|
||||
case TypeDateTime:
|
||||
return "datetime"
|
||||
case TypeBlob:
|
||||
return "longblob"
|
||||
case TypeJson:
|
||||
return "json"
|
||||
case TypeEnum:
|
||||
return "enum('" + strings.Join(col.EnumVals, "','") + "')"
|
||||
case TypeSet:
|
||||
return "set('" + strings.Join(col.EnumVals, "','") + "')"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
// CreateTable 渲染建表语句
|
||||
func (r *mysqlRenderer) CreateTable(t *TableMeta) string {
|
||||
var lines []string
|
||||
for _, c := range t.Columns {
|
||||
lines = append(lines, " "+r.Column(c))
|
||||
}
|
||||
if pk := pkColumns(t); len(pk) > 0 {
|
||||
quoted := make([]string, len(pk))
|
||||
for i, c := range pk {
|
||||
quoted[i] = quoteTick(c)
|
||||
}
|
||||
lines = append(lines, " PRIMARY KEY ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
sql := "CREATE TABLE IF NOT EXISTS " + quoteTick(t.Name) + " (\n" + strings.Join(lines, ",\n") + "\n)"
|
||||
if t.Comment != "" {
|
||||
sql += " COMMENT='" + strings.ReplaceAll(t.Comment, "'", "''") + "'"
|
||||
}
|
||||
return sql + ";"
|
||||
}
|
||||
|
||||
// CreateIndex 渲染索引语句
|
||||
func (r *mysqlRenderer) CreateIndex(t *TableMeta, idx IndexMeta) string {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
cols := make([]string, len(idx.Columns))
|
||||
for i, c := range idx.Columns {
|
||||
cols[i] = quoteTick(c)
|
||||
}
|
||||
return fmt.Sprintf("CREATE %sINDEX %s ON %s (%s);",
|
||||
unique, quoteTick(idx.Name), quoteTick(t.Name), strings.Join(cols, ","))
|
||||
}
|
||||
|
||||
// DropAllTables 清空全部表
|
||||
func (r *mysqlRenderer) DropAllTables(ctx context.Context, db gdb.DB) error {
|
||||
if _, err := db.Exec(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Exec(ctx, "SET FOREIGN_KEY_CHECKS=1")
|
||||
tables, err := db.Tables(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if _, err = db.Exec(ctx, "DROP TABLE IF EXISTS "+quoteTick(t)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FixSequence 修正自增序列
|
||||
// MySQL 自增自动续接,无需处理
|
||||
func (r *mysqlRenderer) FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error { return nil }
|
||||
|
||||
// ============================================================
|
||||
// PostgreSQL
|
||||
// ============================================================
|
||||
|
||||
// pgsqlRenderer PostgreSQL 方言渲染器
|
||||
type pgsqlRenderer struct{ dialect string }
|
||||
|
||||
// Quote 引用标识符
|
||||
func (r *pgsqlRenderer) Quote(name string) string { return quoteStd(name) }
|
||||
|
||||
// Column 渲染列定义
|
||||
func (r *pgsqlRenderer) Column(col ColumnMeta) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(quoteStd(col.Name) + " " + r.colType(col))
|
||||
if col.AutoInc {
|
||||
b.WriteString(" GENERATED BY DEFAULT AS IDENTITY")
|
||||
}
|
||||
if !col.Nullable && !col.AutoInc {
|
||||
b.WriteString(" NOT NULL")
|
||||
}
|
||||
if !col.AutoInc {
|
||||
if d := defaultLiteral(r.dialect, col.Default); d != "" {
|
||||
b.WriteString(" DEFAULT " + d)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colType 列类型映射
|
||||
func (r *pgsqlRenderer) colType(col ColumnMeta) string {
|
||||
switch col.Type {
|
||||
case TypeBool:
|
||||
return "BOOLEAN"
|
||||
case TypeTinyint, TypeSmallint:
|
||||
return "SMALLINT"
|
||||
case TypeInt:
|
||||
return "INTEGER"
|
||||
case TypeBigint:
|
||||
return "BIGINT"
|
||||
case TypeFloat:
|
||||
return "REAL"
|
||||
case TypeDouble:
|
||||
return "DOUBLE PRECISION"
|
||||
case TypeDecimal:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NUMERIC(%d,%d)", col.Length, col.Scale)
|
||||
}
|
||||
return "NUMERIC"
|
||||
case TypeVarchar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("VARCHAR(%d)", col.Length)
|
||||
}
|
||||
return "VARCHAR"
|
||||
case TypeChar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("CHAR(%d)", col.Length)
|
||||
}
|
||||
return "CHAR(36)"
|
||||
case TypeText, TypeTinytext, TypeMediumText, TypeLongText:
|
||||
return "TEXT"
|
||||
case TypeDate:
|
||||
return "DATE"
|
||||
case TypeTime:
|
||||
return "TIME"
|
||||
case TypeDateTime:
|
||||
return "TIMESTAMP"
|
||||
case TypeBlob:
|
||||
return "BYTEA"
|
||||
case TypeJson:
|
||||
return "JSONB"
|
||||
case TypeEnum, TypeSet:
|
||||
return "VARCHAR(255)" // pgsql 无内建 enum/set,降级为 varchar(值域由应用层保证)
|
||||
}
|
||||
return "TEXT"
|
||||
}
|
||||
|
||||
// CreateTable 渲染建表语句
|
||||
func (r *pgsqlRenderer) CreateTable(t *TableMeta) string {
|
||||
var lines []string
|
||||
for _, c := range t.Columns {
|
||||
lines = append(lines, " "+r.Column(c))
|
||||
}
|
||||
if pk := pkColumns(t); len(pk) > 0 {
|
||||
quoted := make([]string, len(pk))
|
||||
for i, c := range pk {
|
||||
quoted[i] = quoteStd(c)
|
||||
}
|
||||
lines = append(lines, " PRIMARY KEY ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
sql := "CREATE TABLE IF NOT EXISTS " + quoteStd(t.Name) + " (\n" + strings.Join(lines, ",\n") + "\n);"
|
||||
if t.Comment != "" {
|
||||
sql += "\nCOMMENT ON TABLE " + quoteStd(t.Name) + " IS '" + strings.ReplaceAll(t.Comment, "'", "''") + "';"
|
||||
}
|
||||
// 列注释随建表语句一并输出
|
||||
for _, c := range t.Columns {
|
||||
if c.Comment != "" {
|
||||
sql += "\nCOMMENT ON COLUMN " + quoteStd(t.Name) + "." + quoteStd(c.Name) +
|
||||
" IS '" + strings.ReplaceAll(c.Comment, "'", "''") + "';"
|
||||
}
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
// CreateIndex 渲染索引语句
|
||||
func (r *pgsqlRenderer) CreateIndex(t *TableMeta, idx IndexMeta) string {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
cols := make([]string, len(idx.Columns))
|
||||
for i, c := range idx.Columns {
|
||||
cols[i] = quoteStd(c)
|
||||
}
|
||||
return fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s (%s);",
|
||||
unique, quoteStd(idx.Name), quoteStd(t.Name), strings.Join(cols, ","))
|
||||
}
|
||||
|
||||
// DropAllTables 清空全部表
|
||||
func (r *pgsqlRenderer) DropAllTables(ctx context.Context, db gdb.DB) error {
|
||||
rows, err := db.Ctx(ctx).Raw(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = current_schema()").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, err = db.Exec(ctx, "DROP TABLE IF EXISTS "+quoteStd(row["tablename"].String())+" CASCADE"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FixSequence 修正自增序列
|
||||
//
|
||||
// 将序列设置到当前 max(id),下次插入取 max+1;
|
||||
// 空表不处理,IDENTITY 从 1 开始
|
||||
func (r *pgsqlRenderer) FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error {
|
||||
for _, c := range t.Columns {
|
||||
if !c.AutoInc {
|
||||
continue
|
||||
}
|
||||
// setval 到当前 max(id),下次插入取 max+1;空表不动(IDENTITY 从1开始)
|
||||
_, err := db.Exec(ctx, fmt.Sprintf(
|
||||
`SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false)`,
|
||||
t.Name, c.Name, quoteStd(c.Name), quoteStd(t.Name)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SQLite
|
||||
// ============================================================
|
||||
|
||||
// sqliteRenderer SQLite 方言渲染器
|
||||
type sqliteRenderer struct{ dialect string }
|
||||
|
||||
// Quote 引用标识符
|
||||
func (r *sqliteRenderer) Quote(name string) string { return quoteStd(name) }
|
||||
|
||||
// Column 渲染列定义
|
||||
func (r *sqliteRenderer) Column(col ColumnMeta) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(quoteStd(col.Name) + " " + r.colType(col))
|
||||
if col.IsPK && col.AutoInc {
|
||||
b.WriteString(" PRIMARY KEY AUTOINCREMENT")
|
||||
} else {
|
||||
if col.IsPK {
|
||||
b.WriteString(" PRIMARY KEY")
|
||||
}
|
||||
if !col.Nullable {
|
||||
b.WriteString(" NOT NULL")
|
||||
}
|
||||
if d := defaultLiteral(r.dialect, col.Default); d != "" {
|
||||
b.WriteString(" DEFAULT " + d)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colType 列类型映射
|
||||
func (r *sqliteRenderer) colType(col ColumnMeta) string {
|
||||
switch col.Type {
|
||||
case TypeBool, TypeTinyint, TypeSmallint, TypeInt, TypeBigint, TypeDate, TypeTime, TypeDateTime:
|
||||
return "INTEGER"
|
||||
case TypeFloat, TypeDouble, TypeDecimal:
|
||||
return "REAL"
|
||||
case TypeBlob:
|
||||
return "BLOB"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTable 渲染建表语句
|
||||
func (r *sqliteRenderer) CreateTable(t *TableMeta) string {
|
||||
var lines []string
|
||||
for _, c := range t.Columns {
|
||||
lines = append(lines, " "+r.Column(c))
|
||||
}
|
||||
// 复合主键走表级约束(列级 PRIMARY KEY 仅单列生效)
|
||||
if pk := pkColumns(t); len(pk) > 1 || !hasAutoIncCol(t) {
|
||||
if pk := pkColumns(t); len(pk) > 0 {
|
||||
quoted := make([]string, len(pk))
|
||||
for i, c := range pk {
|
||||
quoted[i] = quoteStd(c)
|
||||
}
|
||||
lines = append(lines, " PRIMARY KEY ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
}
|
||||
// sqlite 无表/列注释
|
||||
return "CREATE TABLE IF NOT EXISTS " + quoteStd(t.Name) + " (\n" + strings.Join(lines, ",\n") + "\n);"
|
||||
}
|
||||
|
||||
// CreateIndex 渲染索引语句
|
||||
func (r *sqliteRenderer) CreateIndex(t *TableMeta, idx IndexMeta) string {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
cols := make([]string, len(idx.Columns))
|
||||
for i, c := range idx.Columns {
|
||||
cols[i] = quoteStd(c)
|
||||
}
|
||||
return fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s (%s);",
|
||||
unique, quoteStd(idx.Name), quoteStd(t.Name), strings.Join(cols, ","))
|
||||
}
|
||||
|
||||
// DropAllTables 清空全部表
|
||||
func (r *sqliteRenderer) DropAllTables(ctx context.Context, db gdb.DB) error {
|
||||
rows, err := db.Ctx(ctx).Raw(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = db.Exec(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Exec(ctx, "PRAGMA foreign_keys = ON")
|
||||
for _, row := range rows {
|
||||
if _, err = db.Exec(ctx, "DROP TABLE IF EXISTS "+quoteStd(row["name"].String())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FixSequence 修正自增序列
|
||||
// AUTOINCREMENT 自动续接,无需处理
|
||||
func (r *sqliteRenderer) FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error { return nil }
|
||||
|
||||
// ============================================================
|
||||
// SQL Server(首期实测链路为 mysql→pgsql,mssql/oracle 按定稿方言约定实现,待人工校对)
|
||||
// ============================================================
|
||||
|
||||
// mssqlRenderer SQL Server 方言渲染器
|
||||
type mssqlRenderer struct{ dialect string }
|
||||
|
||||
// Quote 引用标识符
|
||||
func (r *mssqlRenderer) Quote(name string) string { return quoteBracket(name) }
|
||||
|
||||
// Column 渲染列定义
|
||||
func (r *mssqlRenderer) Column(col ColumnMeta) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(quoteBracket(col.Name) + " " + r.colType(col))
|
||||
if col.AutoInc {
|
||||
b.WriteString(" IDENTITY(1,1)")
|
||||
}
|
||||
if !col.Nullable {
|
||||
b.WriteString(" NOT NULL")
|
||||
}
|
||||
if !col.AutoInc {
|
||||
if d := defaultLiteral(r.dialect, col.Default); d != "" {
|
||||
b.WriteString(" DEFAULT " + d)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colType 列类型映射
|
||||
func (r *mssqlRenderer) colType(col ColumnMeta) string {
|
||||
switch col.Type {
|
||||
case TypeBool:
|
||||
return "BIT"
|
||||
case TypeTinyint:
|
||||
return "TINYINT"
|
||||
case TypeSmallint:
|
||||
return "SMALLINT"
|
||||
case TypeInt:
|
||||
return "INT"
|
||||
case TypeBigint:
|
||||
return "BIGINT"
|
||||
case TypeFloat:
|
||||
return "FLOAT"
|
||||
case TypeDouble:
|
||||
return "FLOAT"
|
||||
case TypeDecimal:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("DECIMAL(%d,%d)", col.Length, col.Scale)
|
||||
}
|
||||
return "DECIMAL"
|
||||
case TypeVarchar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NVARCHAR(%d)", col.Length)
|
||||
}
|
||||
return "NVARCHAR(255)"
|
||||
case TypeChar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NCHAR(%d)", col.Length)
|
||||
}
|
||||
return "NCHAR(36)"
|
||||
case TypeText, TypeTinytext, TypeMediumText, TypeLongText, TypeJson, TypeEnum, TypeSet:
|
||||
return "NVARCHAR(MAX)"
|
||||
case TypeDate:
|
||||
return "DATE"
|
||||
case TypeTime:
|
||||
return "TIME"
|
||||
case TypeDateTime:
|
||||
return "DATETIME2"
|
||||
case TypeBlob:
|
||||
return "VARBINARY(MAX)"
|
||||
}
|
||||
return "NVARCHAR(MAX)"
|
||||
}
|
||||
|
||||
// CreateTable 渲染建表语句
|
||||
func (r *mssqlRenderer) CreateTable(t *TableMeta) string {
|
||||
var lines []string
|
||||
for _, c := range t.Columns {
|
||||
lines = append(lines, " "+r.Column(c))
|
||||
}
|
||||
if pk := pkColumns(t); len(pk) > 0 {
|
||||
quoted := make([]string, len(pk))
|
||||
for i, c := range pk {
|
||||
quoted[i] = quoteBracket(c)
|
||||
}
|
||||
lines = append(lines, " PRIMARY KEY ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
sql := "IF OBJECT_ID(N'" + t.Name + "', N'U') IS NULL CREATE TABLE " + quoteBracket(t.Name) +
|
||||
" (\n" + strings.Join(lines, ",\n") + "\n);"
|
||||
// 表注释(扩展属性 MS_Description)
|
||||
if t.Comment != "" {
|
||||
sql += "\nEXEC sp_addextendedproperty N'MS_Description', N'" +
|
||||
strings.ReplaceAll(t.Comment, "'", "''") +
|
||||
"', N'SCHEMA', N'dbo', N'TABLE', N'" + t.Name + "';"
|
||||
}
|
||||
// 列注释(扩展属性 MS_Description)
|
||||
for _, c := range t.Columns {
|
||||
if c.Comment != "" {
|
||||
sql += "\nEXEC sp_addextendedproperty N'MS_Description', N'" +
|
||||
strings.ReplaceAll(c.Comment, "'", "''") +
|
||||
"', N'SCHEMA', N'dbo', N'TABLE', N'" + t.Name + "', N'COLUMN', N'" + c.Name + "';"
|
||||
}
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
// CreateIndex 渲染索引语句
|
||||
func (r *mssqlRenderer) CreateIndex(t *TableMeta, idx IndexMeta) string {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
cols := make([]string, len(idx.Columns))
|
||||
for i, c := range idx.Columns {
|
||||
cols[i] = quoteBracket(c)
|
||||
}
|
||||
// IF NOT EXISTS 保护:重复建索引跳过
|
||||
return fmt.Sprintf("IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = N'%s' AND object_id = OBJECT_ID(N'%s')) "+
|
||||
"CREATE %sINDEX %s ON %s (%s);",
|
||||
idx.Name, t.Name, unique, quoteBracket(idx.Name), quoteBracket(t.Name), strings.Join(cols, ","))
|
||||
}
|
||||
|
||||
// DropAllTables 清空全部表
|
||||
func (r *mssqlRenderer) DropAllTables(ctx context.Context, db gdb.DB) error {
|
||||
// 先禁用全部外键约束,再逐表 DROP
|
||||
fks, err := db.Ctx(ctx).Raw(
|
||||
`SELECT OBJECT_NAME(parent_object_id) AS tbl, name AS fk FROM sys.foreign_keys`).All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fk := range fks {
|
||||
if _, err = db.Exec(ctx, fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT %s",
|
||||
quoteBracket(fk["tbl"].String()), quoteBracket(fk["fk"].String()))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tables, err := db.Tables(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tables {
|
||||
if _, err = db.Exec(ctx, "DROP TABLE "+quoteBracket(t)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FixSequence 修正自增序列
|
||||
func (r *mssqlRenderer) FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error {
|
||||
for _, c := range t.Columns {
|
||||
if !c.AutoInc {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(ctx, fmt.Sprintf(
|
||||
"DBCC CHECKIDENT ('%s', RESEED)", t.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Oracle
|
||||
// ============================================================
|
||||
|
||||
// oracleRenderer Oracle 方言渲染器
|
||||
type oracleRenderer struct{ dialect string }
|
||||
|
||||
// Quote 引用标识符
|
||||
func (r *oracleRenderer) Quote(name string) string { return quoteStd(name) }
|
||||
|
||||
// Column 渲染列定义
|
||||
func (r *oracleRenderer) Column(col ColumnMeta) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(quoteStd(col.Name) + " " + r.colType(col))
|
||||
if col.AutoInc {
|
||||
b.WriteString(" GENERATED BY DEFAULT AS IDENTITY")
|
||||
}
|
||||
if !col.Nullable && !col.AutoInc {
|
||||
b.WriteString(" NOT NULL")
|
||||
}
|
||||
if !col.AutoInc {
|
||||
if d := defaultLiteral(r.dialect, col.Default); d != "" {
|
||||
b.WriteString(" DEFAULT " + d)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// colType 列类型映射
|
||||
func (r *oracleRenderer) colType(col ColumnMeta) string {
|
||||
switch col.Type {
|
||||
case TypeBool, TypeTinyint, TypeSmallint:
|
||||
return "NUMBER(3)"
|
||||
case TypeInt:
|
||||
return "NUMBER(10)"
|
||||
case TypeBigint:
|
||||
return "NUMBER(19)"
|
||||
case TypeFloat:
|
||||
return "BINARY_FLOAT"
|
||||
case TypeDouble:
|
||||
return "BINARY_DOUBLE"
|
||||
case TypeDecimal:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NUMBER(%d,%d)", col.Length, col.Scale)
|
||||
}
|
||||
return "NUMBER"
|
||||
case TypeVarchar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NVARCHAR2(%d)", col.Length)
|
||||
}
|
||||
return "NVARCHAR2(255)"
|
||||
case TypeChar:
|
||||
if col.Length > 0 {
|
||||
return fmt.Sprintf("NCHAR(%d)", col.Length)
|
||||
}
|
||||
return "NCHAR(36)"
|
||||
case TypeText, TypeTinytext, TypeMediumText, TypeLongText, TypeJson, TypeEnum, TypeSet:
|
||||
return "NCLOB"
|
||||
case TypeDate:
|
||||
return "DATE"
|
||||
case TypeTime, TypeDateTime:
|
||||
return "TIMESTAMP"
|
||||
case TypeBlob:
|
||||
return "BLOB"
|
||||
}
|
||||
return "NCLOB"
|
||||
}
|
||||
|
||||
// CreateTable 渲染建表语句
|
||||
func (r *oracleRenderer) CreateTable(t *TableMeta) string {
|
||||
var lines []string
|
||||
for _, c := range t.Columns {
|
||||
lines = append(lines, " "+r.Column(c))
|
||||
}
|
||||
if pk := pkColumns(t); len(pk) > 0 {
|
||||
quoted := make([]string, len(pk))
|
||||
for i, c := range pk {
|
||||
quoted[i] = quoteStd(c)
|
||||
}
|
||||
lines = append(lines, " PRIMARY KEY ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
sql := "CREATE TABLE " + quoteStd(t.Name) + " (\n" + strings.Join(lines, ",\n") + "\n)"
|
||||
if t.Comment != "" {
|
||||
sql += "\nCOMMENT ON TABLE " + quoteStd(t.Name) + " IS '" + strings.ReplaceAll(t.Comment, "'", "''") + "'"
|
||||
}
|
||||
for _, c := range t.Columns {
|
||||
if c.Comment != "" {
|
||||
sql += "\nCOMMENT ON COLUMN " + quoteStd(t.Name) + "." + quoteStd(c.Name) +
|
||||
" IS '" + strings.ReplaceAll(c.Comment, "'", "''") + "'"
|
||||
}
|
||||
}
|
||||
return sql + ";"
|
||||
}
|
||||
|
||||
// CreateIndex 渲染索引语句
|
||||
//
|
||||
// Oracle 无 IF NOT EXISTS,由编排层预检索引存在性后决定是否执行
|
||||
func (r *oracleRenderer) CreateIndex(t *TableMeta, idx IndexMeta) string {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
cols := make([]string, len(idx.Columns))
|
||||
for i, c := range idx.Columns {
|
||||
cols[i] = quoteStd(c)
|
||||
}
|
||||
// oracle 无 IF NOT EXISTS,由编排层预检索引存在性后决定是否执行
|
||||
return fmt.Sprintf("CREATE %sINDEX %s ON %s (%s);",
|
||||
unique, quoteStd(idx.Name), quoteStd(t.Name), strings.Join(cols, ","))
|
||||
}
|
||||
|
||||
// DropAllTables 清空全部表
|
||||
func (r *oracleRenderer) DropAllTables(ctx context.Context, db gdb.DB) error {
|
||||
rows, err := db.Ctx(ctx).Raw("SELECT table_name FROM user_tables").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, err = db.Exec(ctx, "DROP TABLE "+quoteStd(row["table_name"].String())+" CASCADE CONSTRAINTS PURGE"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FixSequence 修正自增序列
|
||||
//
|
||||
// Oracle 的 IDENTITY 由缓存失效自动续接,无需显式修序
|
||||
func (r *oracleRenderer) FixSequence(ctx context.Context, db gdb.DB, t *TableMeta) error {
|
||||
for _, c := range t.Columns {
|
||||
if !c.AutoInc {
|
||||
continue
|
||||
}
|
||||
// oracle identity 由缓存失效自动续接,无需显式修序
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
// ============================== 源库结构内省 ==============================
|
||||
|
||||
// listTables 查询源库表清单
|
||||
//
|
||||
// 受白名单过滤,按表名排序以保证结果可复现
|
||||
func listTables(ctx context.Context, db gdb.DB, whitelist []string) ([]string, error) {
|
||||
tables, err := db.Tables(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(whitelist) > 0 {
|
||||
allow := make(map[string]bool, len(whitelist))
|
||||
for _, t := range whitelist {
|
||||
allow[strings.TrimSpace(t)] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(tables))
|
||||
for _, t := range tables {
|
||||
if allow[t] {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
tables = filtered
|
||||
}
|
||||
sort.Strings(tables)
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// introspectTable 单表全量内省
|
||||
//
|
||||
// 采集列信息(含注释、默认值、自增、主键)、二级索引与表注释
|
||||
func introspectTable(ctx context.Context, db gdb.DB, dialect, table string) (*TableMeta, error) {
|
||||
fields, err := db.TableFields(ctx, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := &TableMeta{Name: table}
|
||||
// TableFields 返回 map,按 Index 排序还原物理列序
|
||||
sorted := make([]*gdb.TableField, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
sorted = append(sorted, f)
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Index < sorted[j].Index })
|
||||
for _, f := range sorted {
|
||||
typ, length, scale, enumVals := normalizeType(dialect, f.Type)
|
||||
meta.Columns = append(meta.Columns, ColumnMeta{
|
||||
Name: f.Name,
|
||||
Type: typ,
|
||||
Length: length,
|
||||
Scale: scale,
|
||||
EnumVals: enumVals,
|
||||
Nullable: f.Null,
|
||||
Default: cleanDefault(dialect, f.Default),
|
||||
AutoInc: isAutoInc(f.Extra),
|
||||
Comment: f.Comment,
|
||||
IsPK: strings.EqualFold(f.Key, "PRI") || strings.EqualFold(f.Key, "pri"),
|
||||
})
|
||||
}
|
||||
// 索引与表注释(方言相关)
|
||||
switch dialect {
|
||||
case "mysql":
|
||||
if err = introspectIndexesMySQL(ctx, db, table, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Comment = tableCommentMySQL(ctx, db, table)
|
||||
case "pgsql":
|
||||
if err = introspectIndexesPgsql(ctx, db, table, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Comment = tableCommentPgsql(ctx, db, table)
|
||||
default:
|
||||
// 其余方言:主键经列元数据已识别,二级索引降级跳过(首期实测链路 mysql→pgsql)
|
||||
}
|
||||
// 统一索引命名(迁移后全库一种规则,见 normalizeIndexName)
|
||||
normalizeIndexes(meta)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// normalizeIndexes 归一化二级索引名
|
||||
//
|
||||
// 主键随建表落地,不包含在索引集合中
|
||||
func normalizeIndexes(meta *TableMeta) {
|
||||
for i := range meta.Indexes {
|
||||
idx := &meta.Indexes[i]
|
||||
idx.Name = normalizeIndexName(meta.Name, idx.Unique, idx.Columns)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeIndexName 统一索引命名
|
||||
//
|
||||
// 唯一索引使用 uk_ 前缀,普通索引使用 idx_ 前缀,后接表名与列名;
|
||||
// 名称超过 63 字符时截断,兼容多数数据库的索引名长度上限
|
||||
func normalizeIndexName(table string, unique bool, cols []string) string {
|
||||
prefix := "idx"
|
||||
if unique {
|
||||
prefix = "uk"
|
||||
}
|
||||
name := prefix + "_" + table + "_" + strings.Join(cols, "_")
|
||||
if len(name) > 63 {
|
||||
name = name[:63]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// isAutoInc 判断是否自增列
|
||||
//
|
||||
// 兼容 MySQL 的 auto_increment 以及 PostgreSQL、SQL Server 的 identity
|
||||
func isAutoInc(extra string) bool {
|
||||
e := strings.ToLower(extra)
|
||||
return strings.Contains(e, "auto_increment") || strings.Contains(e, "identity")
|
||||
}
|
||||
|
||||
// introspectIndexesMySQL 采集 MySQL 索引
|
||||
//
|
||||
// 主键写入列元数据,其余二级索引写入索引集合
|
||||
func introspectIndexesMySQL(ctx context.Context, db gdb.DB, table string, meta *TableMeta) error {
|
||||
rows, err := db.Ctx(ctx).Raw("SHOW INDEX FROM `" + table + "`").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type idxHolder struct {
|
||||
unique bool
|
||||
cols []string
|
||||
}
|
||||
holders := map[string]*idxHolder{}
|
||||
order := make([]string, 0)
|
||||
for _, r := range rows {
|
||||
name := r["Key_name"].String()
|
||||
h, ok := holders[name]
|
||||
if !ok {
|
||||
h = &idxHolder{unique: r["Non_unique"].Int() == 0}
|
||||
holders[name] = h
|
||||
order = append(order, name)
|
||||
}
|
||||
h.cols = append(h.cols, r["Column_name"].String())
|
||||
}
|
||||
for _, name := range order {
|
||||
h := holders[name]
|
||||
if name == "PRIMARY" {
|
||||
for i := range meta.Columns {
|
||||
for _, c := range h.cols {
|
||||
if meta.Columns[i].Name == c {
|
||||
meta.Columns[i].IsPK = true
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
meta.Indexes = append(meta.Indexes, IndexMeta{Unique: h.unique, Name: name, Columns: h.cols})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableCommentMySQL 查询 MySQL 表注释
|
||||
func tableCommentMySQL(ctx context.Context, db gdb.DB, table string) string {
|
||||
row, err := db.Ctx(ctx).Raw(
|
||||
"SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", table,
|
||||
).One()
|
||||
if err != nil || len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
return row["TABLE_COMMENT"].String()
|
||||
}
|
||||
|
||||
// introspectIndexesPgsql 采集 PostgreSQL 索引
|
||||
//
|
||||
// 主键写入列元数据,其余二级索引写入索引集合
|
||||
func introspectIndexesPgsql(ctx context.Context, db gdb.DB, table string, meta *TableMeta) error {
|
||||
rows, err := db.Ctx(ctx).Raw(
|
||||
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ?", table,
|
||||
).All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
name := r["indexname"].String()
|
||||
def := r["indexdef"].String()
|
||||
cols := parsePgIndexCols(def)
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
if name == table+"_pkey" {
|
||||
for i := range meta.Columns {
|
||||
for _, c := range cols {
|
||||
if meta.Columns[i].Name == c {
|
||||
meta.Columns[i].IsPK = true
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
meta.Indexes = append(meta.Indexes, IndexMeta{
|
||||
Unique: strings.Contains(strings.ToUpper(def), "UNIQUE"),
|
||||
Name: name,
|
||||
Columns: cols,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePgIndexCols 从建索引语句中提取列清单
|
||||
//
|
||||
// 取括号内内容按逗号切分,跳过表达式部分
|
||||
func parsePgIndexCols(def string) []string {
|
||||
start := strings.Index(def, "(")
|
||||
end := strings.LastIndex(def, ")")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(def[start+1:end], ",")
|
||||
cols := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
// 表达式索引(如 lower(name))取首标识符,跳过复杂表达式
|
||||
if i := strings.IndexAny(p, " ("); i > 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
p = strings.Trim(p, `"`)
|
||||
if p != "" {
|
||||
cols = append(cols, p)
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// tableCommentPgsql 查询 PostgreSQL 表注释
|
||||
func tableCommentPgsql(ctx context.Context, db gdb.DB, table string) string {
|
||||
row, err := db.Ctx(ctx).Raw(
|
||||
"SELECT obj_description(c.oid) AS comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relname = ?", table,
|
||||
).One()
|
||||
if err != nil || len(row) == 0 {
|
||||
return ""
|
||||
}
|
||||
return row["comment"].String()
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
// ============================== 整库迁移主流程 ==============================
|
||||
|
||||
// banner 迁移起止分隔线
|
||||
const banner = "=============================================================="
|
||||
|
||||
// Run 整库迁移主流程
|
||||
//
|
||||
// 共五个阶段:建目标库 → 建表结构 → 逐表迁移数据 → 重置自增序列 → 逐表行数校验。
|
||||
// progress 为进度输出回调,CLI 传入格式化输出函数,为 nil 时静默。
|
||||
func Run(ctx context.Context, opt Options, progress func(format string, args ...any)) (*Report, error) {
|
||||
report := &Report{}
|
||||
logf := func(format string, args ...any) {
|
||||
if progress != nil {
|
||||
progress(format, args...)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
|
||||
src, err := openConn(ctx, "migrate_src", opt.Src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables, err := listTables(ctx, src, opt.Tables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取源库表清单失败: %v", err)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("源库无表(或白名单未命中任何表)")
|
||||
}
|
||||
report.TableTotal = len(tables)
|
||||
|
||||
renderer, err := NewRenderer(opt.Dst.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 抬头
|
||||
logf("%s\n", banner)
|
||||
logf("[迁移开始] 源: %s:%s → 目标: %s:%s\n", opt.Src.Type, opt.Src.Database, opt.Dst.Type, opt.Dst.Database)
|
||||
logf("[迁移开始] 待迁移 %d 张表\n", len(tables))
|
||||
logf("%s\n", banner)
|
||||
|
||||
// ── 1/5 建目标库 ──
|
||||
phase := time.Now()
|
||||
logf("[·] [1/5] 建目标库...\n")
|
||||
if err = ensureDatabase(ctx, opt.Dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logf(" · 目标库 \"%s\" 已就绪\n", opt.Dst.Database)
|
||||
logf("[✓] [1/5] 建目标库 完成(耗时 %s)\n", dur(time.Since(phase)))
|
||||
|
||||
dst, err := openConn(ctx, "migrate_dst", opt.Dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ── 2/5 建表结构 ──
|
||||
phase = time.Now()
|
||||
logf("[·] [2/5] 建表结构(%d 张表)...\n", len(tables))
|
||||
if opt.DropTargetFirst {
|
||||
if err = renderer.DropAllTables(ctx, dst); err != nil {
|
||||
return nil, fmt.Errorf("清空目标库失败: %v", err)
|
||||
}
|
||||
}
|
||||
metas := make([]*TableMeta, 0, len(tables))
|
||||
totalIndexes := 0
|
||||
for _, table := range tables {
|
||||
meta, e := introspectTable(ctx, src, opt.Src.Type, table)
|
||||
if e != nil {
|
||||
report.TableFailed++
|
||||
report.Failed = append(report.Failed, FailedTable{Table: table, Err: "内省失败: " + e.Error()})
|
||||
logf(" [✗ %s] 内省失败: %v\n", table, e)
|
||||
continue
|
||||
}
|
||||
// 非清空模式下目标已有同名表则该表跳过(建表用 IF NOT EXISTS,数据会追加,须前置拦截)
|
||||
if !opt.DropTargetFirst {
|
||||
if exists, e2 := tableExists(ctx, dst, table); e2 == nil && exists {
|
||||
report.TableFailed++
|
||||
report.Failed = append(report.Failed, FailedTable{Table: table, Err: "目标库已存在同名表"})
|
||||
logf(" [✗ %s] 目标库已存在同名表\n", table)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, e = dst.Exec(ctx, renderer.CreateTable(meta)); e != nil {
|
||||
report.TableFailed++
|
||||
report.Failed = append(report.Failed, FailedTable{Table: table, Err: "建表失败: " + e.Error()})
|
||||
logf(" [✗ %s] 建表失败: %v\n", table, e)
|
||||
continue
|
||||
}
|
||||
for _, idx := range meta.Indexes {
|
||||
if _, e = dst.Exec(ctx, renderer.CreateIndex(meta, idx)); e != nil {
|
||||
report.TableFailed++
|
||||
report.Failed = append(report.Failed, FailedTable{Table: table, Err: "建索引 " + idx.Name + " 失败: " + e.Error()})
|
||||
logf(" [✗ %s] 建索引 %s 失败: %v\n", table, idx.Name, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
totalIndexes += len(meta.Indexes)
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
logf("[✓] [2/5] 建表结构 完成(耗时 %s),%d 张表,%d 个索引,0 个外键\n",
|
||||
dur(time.Since(phase)), len(metas), totalIndexes)
|
||||
|
||||
// ── 3/5 逐表迁移数据 ──
|
||||
phase = time.Now()
|
||||
logf("[·] [3/5] 逐表迁移数据...\n")
|
||||
totalRows := int64(0)
|
||||
for i, meta := range metas {
|
||||
logf(" · [%d/%d] 正在迁移 %s...\n", i+1, len(metas), meta.Name)
|
||||
tableStart := time.Now()
|
||||
rows, e := copyTable(ctx, src, dst, renderer, meta, opt.BatchSize, func(batch, total int64) {
|
||||
logf(" [%s] +%d(累计 %d)\n", meta.Name, batch, total)
|
||||
})
|
||||
if e != nil {
|
||||
report.TableFailed++
|
||||
report.Failed = append(report.Failed, FailedTable{Table: meta.Name, Err: "数据搬运失败: " + e.Error()})
|
||||
logf(" [✗ %s] 失败: %v\n", meta.Name, e)
|
||||
continue
|
||||
}
|
||||
report.TableOK++
|
||||
totalRows += rows
|
||||
logf(" [✓ %s] 共 %d 行(耗时 %s)\n", meta.Name, rows, dur(time.Since(tableStart)))
|
||||
}
|
||||
report.RowTotal = totalRows
|
||||
logf("[✓] [3/5] 逐表迁移数据 完成(耗时 %s),共 %d 行\n", dur(time.Since(phase)), totalRows)
|
||||
|
||||
// ── 4/5 重置自增序列 ──
|
||||
phase = time.Now()
|
||||
logf("[·] [4/5] 重置自增序列...\n")
|
||||
for _, meta := range metas {
|
||||
if e := renderer.FixSequence(ctx, dst, meta); e != nil {
|
||||
report.Failed = append(report.Failed, FailedTable{Table: meta.Name, Err: "序列修复失败: " + e.Error()})
|
||||
}
|
||||
}
|
||||
logf("[✓] [4/5] 重置自增序列 完成(耗时 %s)\n", dur(time.Since(phase)))
|
||||
|
||||
// ── 5/5 逐表行数校验 ──
|
||||
phase = time.Now()
|
||||
logf("[·] [5/5] 逐表行数校验...\n")
|
||||
allPass := true
|
||||
for _, meta := range metas {
|
||||
srcCount, e1 := countRows(ctx, src, meta.Name)
|
||||
dstCount, e2 := countRows(ctx, dst, meta.Name)
|
||||
if e1 != nil || e2 != nil || srcCount != dstCount {
|
||||
allPass = false
|
||||
logf(" [%s] source=%d target=%d FAIL\n", meta.Name, srcCount, dstCount)
|
||||
continue
|
||||
}
|
||||
logf(" [%s] source=%d target=%d PASS\n", meta.Name, srcCount, dstCount)
|
||||
}
|
||||
if allPass {
|
||||
logf("[✓] [5/5] 逐表行数校验 完成(耗时 %s),%d 张表全部 PASS\n", dur(time.Since(phase)), len(metas))
|
||||
} else {
|
||||
logf("[✓] [5/5] 逐表行数校验 完成(耗时 %s),存在 FAIL\n", dur(time.Since(phase)))
|
||||
}
|
||||
|
||||
// 落款
|
||||
logf("%s\n", banner)
|
||||
logf("[迁移完成] 成功:%d 行 / %d 张表 / 总耗时 %s\n", totalRows, report.TableOK, dur(time.Since(start)))
|
||||
logf("%s\n", banner)
|
||||
|
||||
if report.TableFailed > 0 {
|
||||
return report, fmt.Errorf("%d 张表迁移失败", report.TableFailed)
|
||||
}
|
||||
if !allPass {
|
||||
return report, fmt.Errorf("存在行数校验不一致的表")
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// countRows 统计表行数
|
||||
func countRows(ctx context.Context, db gdb.DB, table string) (int64, error) {
|
||||
n, err := db.Ctx(ctx).Model(table).Count()
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
// dur 时长格式化
|
||||
//
|
||||
// 不足 1 秒输出毫秒,不足 1 分输出秒,否则输出分秒
|
||||
func dur(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
// Package migrate 整库迁移
|
||||
//
|
||||
// 处理流程:源库内省 → 中间表示 → 目标方言 DDL → 分批搬运数据 → 序列修复。
|
||||
// 独立于业务基座(不依赖 dao 与 logic),经 gdb 动态连接源库与目标库进行裸读裸写,
|
||||
// 全程不触发全局钩子(租户隔离、软删除、审计盖章),保证数据原样落库。
|
||||
package migrate
|
||||
|
||||
// ============================== 中间类型常量 ==============================
|
||||
|
||||
// 中间类型常量
|
||||
//
|
||||
// 源方言类型归一后的规范形态,各方言渲染器再翻译为目标方言
|
||||
const (
|
||||
TypeBool = "bool"
|
||||
TypeTinyint = "tinyint"
|
||||
TypeSmallint = "smallint"
|
||||
TypeInt = "int"
|
||||
TypeBigint = "bigint"
|
||||
TypeFloat = "float"
|
||||
TypeDouble = "double"
|
||||
TypeDecimal = "decimal"
|
||||
TypeVarchar = "varchar"
|
||||
TypeChar = "char"
|
||||
TypeText = "text"
|
||||
TypeTinytext = "tinytext"
|
||||
TypeMediumText = "mediumtext"
|
||||
TypeLongText = "longtext"
|
||||
TypeDate = "date"
|
||||
TypeTime = "time"
|
||||
TypeDateTime = "datetime"
|
||||
TypeBlob = "blob"
|
||||
TypeJson = "json"
|
||||
TypeEnum = "enum"
|
||||
TypeSet = "set"
|
||||
TypeOther = "other"
|
||||
)
|
||||
|
||||
// ============================== 迁移任务参数 ==============================
|
||||
|
||||
// Options 迁移任务参数
|
||||
type Options struct {
|
||||
Src DSN // 源库连接
|
||||
Dst DSN // 目标库连接
|
||||
|
||||
DropTargetFirst bool // 迁移前清空目标库全部表
|
||||
Tables []string // 表白名单(空=全部)
|
||||
BatchSize int // 数据搬运批大小(默认 500)
|
||||
}
|
||||
|
||||
// DSN 数据库连接要素
|
||||
//
|
||||
// SQLite 仅使用 Database 作为文件路径,Host 与 Port 忽略
|
||||
type DSN struct {
|
||||
Type string // 驱动名:mysql/pgsql/sqlite/mssql/oracle
|
||||
Host string // 主机
|
||||
Port string // 端口
|
||||
Database string // 数据库名或文件路径
|
||||
Username string // 用户名
|
||||
Password string // 密码
|
||||
}
|
||||
|
||||
// ============================== 元数据中间表示 ==============================
|
||||
|
||||
// TableMeta 源库表元数据
|
||||
type TableMeta struct {
|
||||
Name string // 表名
|
||||
Comment string // 表注释
|
||||
Columns []ColumnMeta // 列集合
|
||||
Indexes []IndexMeta // 二级索引集合
|
||||
}
|
||||
|
||||
// ColumnMeta 列元数据
|
||||
//
|
||||
// Type 为中间类型常量
|
||||
type ColumnMeta struct {
|
||||
Name string // 列名
|
||||
Type string // 中间类型
|
||||
Length int // varchar/char 长度;decimal 精度
|
||||
Scale int // decimal 小数位
|
||||
EnumVals []string // 枚举值集合
|
||||
Nullable bool // 是否可空
|
||||
Default string // 默认值原文(空=无默认);表达式类原样保留
|
||||
AutoInc bool // 是否自增
|
||||
Comment string // 列注释
|
||||
IsPK bool // 是否主键
|
||||
}
|
||||
|
||||
// IndexMeta 索引元数据
|
||||
//
|
||||
// 不含主键,主键经 ColumnMeta.IsPK 表达
|
||||
type IndexMeta struct {
|
||||
Unique bool // 是否唯一索引
|
||||
Name string // 索引名
|
||||
Columns []string // 索引列集合
|
||||
}
|
||||
|
||||
// ============================== 迁移汇总 ==============================
|
||||
|
||||
// Report 迁移汇总
|
||||
type Report struct {
|
||||
TableTotal int // 待迁移表总数
|
||||
TableOK int // 成功表数
|
||||
TableFailed int // 失败表数
|
||||
RowTotal int64 // 总搬运行数
|
||||
Failed []FailedTable // 失败表记录
|
||||
}
|
||||
|
||||
// FailedTable 失败表记录
|
||||
type FailedTable struct {
|
||||
Table string // 表名
|
||||
Err string // 错误信息
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// +======================================================================
|
||||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||||
// +----------------------------------------------------------------------
|
||||
// | 开源协议: Apache-2.0
|
||||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||||
// +----------------------------------------------------------------------
|
||||
// | 官方网站: https://www.easygoadmin.vip
|
||||
// +----------------------------------------------------------------------
|
||||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||||
// +======================================================================
|
||||
// | 版权和免责声明:
|
||||
// | 一、知识产权声明
|
||||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||||
// | 二、使用限制与禁止条款
|
||||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||||
// | 三、责任豁免
|
||||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||||
// | 四、违约与法律责任
|
||||
// | 如发现任何违反本声明的行为,本团队有权:
|
||||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||||
// | 五、许可范围说明
|
||||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||||
// | 成的一切经济损失及商誉损害。
|
||||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||||
// | 许可协议》附件为准。
|
||||
// +======================================================================
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ============================== 类型归一 ==============================
|
||||
|
||||
var (
|
||||
reParen = regexp.MustCompile(`\(([^)]*)\)`) // (11) / (10,2) / ('a','b')
|
||||
reUnsigned = regexp.MustCompile(`(?i)\bunsigned\b`) // mysql unsigned 修饰
|
||||
rePgCast = regexp.MustCompile(`(?i)^'(.*)'::[\w\s\[\]]+$`) // pgsql 'x'::character varying
|
||||
rePgFunc = regexp.MustCompile(`(?i)^(current_timestamp|now|localtime)\(\d*\)$`)
|
||||
)
|
||||
|
||||
// normalizeType 源方言原始列类型转换为中间类型
|
||||
//
|
||||
// 同时解析长度、精度与枚举值
|
||||
func normalizeType(dialect, rawType string) (typ string, length, scale int, enumVals []string) {
|
||||
t := strings.ToLower(strings.TrimSpace(rawType))
|
||||
unsigned := reUnsigned.MatchString(t)
|
||||
t = reUnsigned.ReplaceAllString(t, "")
|
||||
t = strings.TrimSpace(t)
|
||||
|
||||
// 提取括号参数(长度/精度/枚举值)
|
||||
pargs := ""
|
||||
if m := reParen.FindStringSubmatch(t); m != nil {
|
||||
pargs = strings.TrimSpace(m[1])
|
||||
t = strings.TrimSpace(reParen.ReplaceAllString(t, ""))
|
||||
}
|
||||
// 剥离 pgsql 时区/精度修饰
|
||||
t = strings.TrimSuffix(t, " without time zone")
|
||||
t = strings.TrimSuffix(t, " with time zone")
|
||||
|
||||
// 枚举/集合:括号内为引号列表
|
||||
if strings.HasPrefix(t, "enum") || strings.HasPrefix(t, "set") || strings.HasPrefix(t, "enum(") {
|
||||
for _, v := range strings.Split(pargs, ",") {
|
||||
if v = strings.Trim(strings.TrimSpace(v), "'\""); v != "" {
|
||||
enumVals = append(enumVals, v)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(t, "set") {
|
||||
return TypeSet, 0, 0, enumVals
|
||||
}
|
||||
return TypeEnum, 0, 0, enumVals
|
||||
}
|
||||
|
||||
// decimal/numeric:pargs = "10,2"
|
||||
if parts := strings.Split(pargs, ","); len(parts) == 2 {
|
||||
if p, e1 := strconv.Atoi(strings.TrimSpace(parts[0])); e1 == nil {
|
||||
if s, e2 := strconv.Atoi(strings.TrimSpace(parts[1])); e2 == nil {
|
||||
return TypeDecimal, p, s, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单参数括号:varchar(150) / int(11)
|
||||
if pargs != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(strings.Split(pargs, ",")[0])); err == nil {
|
||||
length = n
|
||||
}
|
||||
}
|
||||
|
||||
switch t {
|
||||
case "bool", "boolean", "bit":
|
||||
return TypeBool, 0, 0, nil
|
||||
case "tinyint":
|
||||
// mysql tinyint(1) 惯例为布尔
|
||||
if length == 1 {
|
||||
return TypeBool, 0, 0, nil
|
||||
}
|
||||
if unsigned {
|
||||
return TypeSmallint, 0, 0, nil
|
||||
}
|
||||
return TypeTinyint, 0, 0, nil
|
||||
case "smallint", "year":
|
||||
return TypeSmallint, 0, 0, nil
|
||||
case "mediumint", "int", "integer":
|
||||
return TypeInt, 0, 0, nil
|
||||
case "bigint":
|
||||
return TypeBigint, 0, 0, nil
|
||||
case "float", "real", "binary_float":
|
||||
return TypeFloat, 0, 0, nil
|
||||
case "double", "double precision", "float8", "binary_double":
|
||||
return TypeDouble, 0, 0, nil
|
||||
case "decimal", "numeric", "number", "dec", "fixed":
|
||||
if length == 0 {
|
||||
return TypeDecimal, 0, 0, nil
|
||||
}
|
||||
return TypeDecimal, length, scale, nil
|
||||
case "varchar", "character varying", "nvarchar", "varchar2":
|
||||
return TypeVarchar, length, 0, nil
|
||||
case "char", "character", "nchar", "char2":
|
||||
return TypeChar, length, 0, nil
|
||||
case "tinytext":
|
||||
return TypeTinytext, 0, 0, nil
|
||||
case "text", "mediumtext", "longtext", "clob", "ntext", "json", "jsonb", "xml":
|
||||
if t == "json" || t == "jsonb" {
|
||||
return TypeJson, 0, 0, nil
|
||||
}
|
||||
if t == "mediumtext" {
|
||||
return TypeMediumText, 0, 0, nil
|
||||
}
|
||||
if t == "longtext" {
|
||||
return TypeLongText, 0, 0, nil
|
||||
}
|
||||
return TypeText, 0, 0, nil
|
||||
case "date":
|
||||
return TypeDate, 0, 0, nil
|
||||
case "time":
|
||||
return TypeTime, 0, 0, nil
|
||||
case "datetime", "timestamp", "smalldatetime", "datetime2":
|
||||
return TypeDateTime, 0, 0, nil
|
||||
case "tinyblob", "blob", "mediumblob", "longblob", "binary", "varbinary",
|
||||
"bytea", "image", "raw", "long raw":
|
||||
return TypeBlob, 0, 0, nil
|
||||
case "uuid":
|
||||
return TypeChar, 36, 0, nil
|
||||
}
|
||||
return TypeOther, 0, 0, nil
|
||||
}
|
||||
|
||||
// cleanDefault 归一默认值表达式
|
||||
//
|
||||
// PostgreSQL 剥去类型转换与函数括号,其余方言原样保留
|
||||
func cleanDefault(dialect string, raw any) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(toString(raw))
|
||||
if s == "" || strings.EqualFold(s, "null") {
|
||||
return ""
|
||||
}
|
||||
if dialect == "pgsql" {
|
||||
if m := rePgCast.FindStringSubmatch(s); m != nil {
|
||||
s = m[1]
|
||||
} else if rePgFunc.MatchString(s) {
|
||||
s = "CURRENT_TIMESTAMP"
|
||||
}
|
||||
}
|
||||
if dialect == "mysql" && strings.EqualFold(s, "current_timestamp()") {
|
||||
s = "CURRENT_TIMESTAMP"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toString 将任意默认值转为字符串
|
||||
// 兼容不同驱动的返回类型差异
|
||||
func toString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case []byte:
|
||||
return string(t)
|
||||
default:
|
||||
return strings.TrimSpace(strings.TrimSuffix(strval(t), ".0"))
|
||||
}
|
||||
}
|
||||
|
||||
// strval 数值转字符串
|
||||
func strval(v any) string {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return strings.TrimSpace(toStr(v))
|
||||
}
|
||||
}
|
||||
|
||||
// toStr 尝试调用 String 方法转为字符串
|
||||
func toStr(v any) string {
|
||||
if s, ok := v.(interface{ String() string }); ok {
|
||||
return s.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user