From 764eccfafd70577af54eabb0527efc972e8dabb8 Mon Sep 17 00:00:00 2001 From: what Date: Mon, 24 Mar 2025 16:58:37 +0800 Subject: [PATCH] =?UTF-8?q?[feat]=20=E6=96=B0=E5=A2=9E=20=E8=A1=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/blueprint.go | 446 +++++++++++++++++++++ schema/builder.go | 95 +++++ schema/column_definition.go | 168 ++++++++ schema/dialect/mysql/mysql.go | 279 +++++++++++++ schema/dialect/mysql/mysql_builder_test.go | 109 +++++ schema/dialect/mysql/mysql_test.go | 190 +++++++++ schema/dialect/postgres/postgres.go | 213 ++++++++++ schema/dialect/postgres/postgres_test.go | 182 +++++++++ schema/dialect/sqlite3/sqlite3.go | 264 ++++++++++++ schema/dialect/sqlite3/sqlite3_test.go | 192 +++++++++ schema/dialect/sqlserver/sqlserver.go | 201 ++++++++++ schema/dialect/sqlserver/sqlserver_test.go | 182 +++++++++ schema/schema.go | 59 +++ schema/util.go | 26 ++ 14 files changed, 2606 insertions(+) create mode 100755 schema/blueprint.go create mode 100755 schema/builder.go create mode 100755 schema/column_definition.go create mode 100644 schema/dialect/mysql/mysql.go create mode 100644 schema/dialect/mysql/mysql_builder_test.go create mode 100644 schema/dialect/mysql/mysql_test.go create mode 100644 schema/dialect/postgres/postgres.go create mode 100644 schema/dialect/postgres/postgres_test.go create mode 100644 schema/dialect/sqlite3/sqlite3.go create mode 100644 schema/dialect/sqlite3/sqlite3_test.go create mode 100644 schema/dialect/sqlserver/sqlserver.go create mode 100644 schema/dialect/sqlserver/sqlserver_test.go create mode 100644 schema/schema.go create mode 100755 schema/util.go diff --git a/schema/blueprint.go b/schema/blueprint.go new file mode 100755 index 0000000..9cf3fd8 --- /dev/null +++ b/schema/blueprint.go @@ -0,0 +1,446 @@ +package schema + +import ( + "strings" + + "github.com/samber/lo" +) + +type Blueprint struct { + table string // the table the blueprint describes. + columns []*ColumnDefinition // columns that should be added to the table + commands []*Command // + temporary bool // Whether to make the table temporary. + charset string // The default character set that should be used for the table. + collation string // The collation that should be used for the table. + engine string // The engine that should be used for the table. +} + +type Command struct { + Type string + CommandOptions +} + +type CommandOptions struct { + Index string // 索引名称 + Columns []string // 索引字段 + Algorithm string // 索引类型如: USING BTREE + To string // 新名词 + From string // 旧名词 +} + +func NewBlueprint(table string) *Blueprint { + return &Blueprint{table: table, charset: "utf8mb4", collation: "utf8mb4_general_ci"} +} + +// 字符串 +func (this *Blueprint) Char(column string, length int) *ColumnDefinition { + if length == 0 { + length = 255 + } + return this.addColumn("char", column, &ColumnOptions{Length: length}) +} + +// 可变长度字符串 +func (this *Blueprint) String(column string, length int) *ColumnDefinition { + if length == 0 { + length = 255 + } + return this.addColumn("string", column, &ColumnOptions{Length: length}) +} + +// 文本 +func (this *Blueprint) Text(column string) *ColumnDefinition { + return this.addColumn("text", column, nil) +} + +// 整型 +func (this *Blueprint) Integer(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + unsigned := false + if len(params) > 0 { + autoIncrement = params[0] + } + if len(params) > 1 { + unsigned = params[1] + } + return this.addColumn("integer", column, &ColumnOptions{autoIncrement: autoIncrement, unsigned: unsigned}) +} + +// 迷你整型 1 byte +func (this *Blueprint) TinyInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + unsigned := false + if len(params) > 0 { + autoIncrement = params[0] + } + if len(params) > 1 { + unsigned = params[1] + } + return this.addColumn("tinyInteger", column, &ColumnOptions{autoIncrement: autoIncrement, unsigned: unsigned}) +} + +// 小整型 2 byte +func (this *Blueprint) SmallInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + unsigned := false + if len(params) > 0 { + autoIncrement = params[0] + } + if len(params) > 1 { + unsigned = params[1] + } + return this.addColumn("smallInteger", column, &ColumnOptions{autoIncrement: autoIncrement, unsigned: unsigned}) +} + +// 大整型 2 byte +func (this *Blueprint) BigInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + unsigned := false + if len(params) > 0 { + autoIncrement = params[0] + } + if len(params) > 1 { + unsigned = params[1] + } + return this.addColumn("bigInteger", column, &ColumnOptions{autoIncrement: autoIncrement, unsigned: unsigned}) +} + +// 无符号整型 +func (this *Blueprint) UnsignedInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + if len(params) > 0 { + autoIncrement = params[0] + } + return this.Integer(column, autoIncrement, true) +} + +// 无符号迷你整型 1 byte +func (this *Blueprint) UnsignedTinyInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + if len(params) > 0 { + autoIncrement = params[0] + } + return this.TinyInteger(column, autoIncrement, true) +} + +// 无符号小整型 2 byte +func (this *Blueprint) UnsignedSmallInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + if len(params) > 0 { + autoIncrement = params[0] + } + return this.SmallInteger(column, autoIncrement, true) +} + +// 无符号大整型 +func (this *Blueprint) UnsignedBigInteger(column string, params ...bool) *ColumnDefinition { + autoIncrement := false + if len(params) > 0 { + autoIncrement = params[0] + } + return this.BigInteger(column, autoIncrement, true) +} + +// 精确小数 +func (this *Blueprint) Decimal(column string, total, places int) *ColumnDefinition { + if total == 0 { + total = 8 + } + if places == 0 { + places = 2 + } + return this.addColumn("decimal", column, &ColumnOptions{Total: total, Places: places}) +} + +// 无符号精确销售 +func (this *Blueprint) UnsignedDecimal(column string, total, places int) *ColumnDefinition { + if total == 0 { + total = 8 + } + if places == 0 { + places = 2 + } + return this.addColumn("decimal", column, &ColumnOptions{Total: total, Places: places, unsigned: true}) +} + +// 布尔值 +func (this *Blueprint) Boolean(column string) *ColumnDefinition { + return this.addColumn("boolean", column, nil) +} + +// 枚举类型 +func (this *Blueprint) Enum(column string, allowed []string) *ColumnDefinition { + return this.addColumn("enum", column, &ColumnOptions{Allowed: allowed}) +} + +// JSON +func (this *Blueprint) Json(column string) *ColumnDefinition { + return this.addColumn("json", column, nil) +} + +// 日期类型 +func (this *Blueprint) Date(column string) *ColumnDefinition { + return this.addColumn("date", column, nil) +} + +// 日期时间类型 +func (this *Blueprint) DateTime(column string, precision ...int) *ColumnDefinition { + if len(precision) > 0 { + return this.addColumn("datetime", column, &ColumnOptions{Precision: precision[0]}) + } + return this.addColumn("datetime", column, nil) +} + +// 时间类型 +func (this *Blueprint) Time(column string, precision ...int) *ColumnDefinition { + if len(precision) > 0 { + return this.addColumn("time", column, &ColumnOptions{Precision: precision[0]}) + } + return this.addColumn("time", column, nil) +} + +// 时间戳 +func (this *Blueprint) Timestamp(column string, precision ...int) *ColumnDefinition { + if len(precision) > 0 { + return this.addColumn("timestamp", column, &ColumnOptions{Precision: precision[0]}) + } + return this.addColumn("timestamp", column, nil) +} + +// 年 +func (this *Blueprint) Year(column string) *ColumnDefinition { + return this.addColumn("year", column, nil) +} + +// 二进制数据 +func (this *Blueprint) Binary(column string) *ColumnDefinition { + return this.addColumn("binary", column, nil) +} + +// UUID +func (this *Blueprint) Uuid(column string) *ColumnDefinition { + return this.addColumn("uuid", column, nil) +} + +// 自增字段 +func (this *Blueprint) Increments(column string) *ColumnDefinition { + return this.UnsignedInteger(column, true) +} + +// 自增Big字段 +func (this *Blueprint) BigIncrements(column string) *ColumnDefinition { + return this.UnsignedBigInteger(column, true) +} + +// 添加主键 +func (this *Blueprint) Primary(columns ...string) *Command { + return this.addCommand("primary", CommandOptions{Index: this.generateIndexName("pk", columns), Columns: columns}) +} + +// 唯一键 +func (this *Blueprint) Unique(columns ...string) *Command { + return this.addCommand("unique", CommandOptions{Index: this.generateIndexName("unique", columns), Columns: columns}) +} + +// 普通索引 +func (this *Blueprint) Index(columns ...string) *Command { + return this.addCommand("index", CommandOptions{Index: this.generateIndexName("index", columns), Columns: columns}) +} + +// 空间索引 +func (this *Blueprint) SpatialIndex(columns ...string) *Command { + return this.addCommand("spatialIndex", CommandOptions{Index: this.generateIndexName("spatial_index", columns), Columns: columns}) +} + +// 删除列 +func (this *Blueprint) DropColumn(columns ...string) *Command { + return this.addCommand("dropColumn", CommandOptions{Columns: columns}) +} + +// 创建表 +func (this *Blueprint) Create() *Command { + return this.addCommand("create", CommandOptions{}) +} + +// 设置临时表标记 +func (this *Blueprint) Temporary() { + this.temporary = true +} + +// 设置表字符集 +func (this *Blueprint) Charset(charset string) { + this.charset = charset +} + +// 修改表名 +func (this *Blueprint) Rename(to string) *Command { + return this.addCommand("rename", CommandOptions{To: to}) +} + +// 删除表 +func (this *Blueprint) Drop() *Command { + return this.addCommand("drop", CommandOptions{}) +} + +// 删除表, 先判断再删除 +func (this *Blueprint) DropIfExists() *Command { + return this.addCommand("dropIfExists", CommandOptions{}) +} + +func (this *Blueprint) ToSql(sc Schema) (statements []string) { + this.addImpliedCommands(sc) + + for _, cmd := range this.commands { + switch cmd.Type { + case "create": + statements = append(statements, sc.CompileCreate(this)...) + case "add": + statements = append(statements, sc.CompileAdd(this)...) + case "change": + statements = append(statements, sc.CompileChange(this)...) + case "drop": + statements = append(statements, sc.CompileDrop(this)...) + case "dropIfExists": + statements = append(statements, sc.CompileDropIfExists(this)...) + case "dropColumn": + statements = append(statements, sc.CompileDropColumn(this)...) + case "rename": + statements = append(statements, sc.CompileRename(this)...) + } + } + + return statements +} + +// 判断是否是创建表 +func (this *Blueprint) creating() bool { + return lo.SomeBy(this.commands, func(item *Command) bool { + if item.Type == "create" { + return true + } + return false + }) +} + +func (this *Blueprint) addColumn(typ, name string, options *ColumnOptions) (definition *ColumnDefinition) { + definition = &ColumnDefinition{Type: typ, Name: name} + + if options != nil { + if options.Length > 0 { + definition.Length = options.Length + } + if options.autoIncrement { + definition.autoIncrement = true + } + if options.unsigned { + definition.unsigned = true + } + if options.Total > 0 { + definition.Total = options.Total + } + if options.Places > 0 { + definition.Places = options.Places + } + if len(options.Allowed) > 0 { + definition.Allowed = options.Allowed + } + if options.Precision > 0 { + definition.Precision = options.Precision + } + if options.change { + definition.change = true + } + } + this.columns = append(this.columns, definition) + return definition +} + +func (this *Blueprint) addImpliedCommands(sc Schema) { + if !this.creating() { + if len(this.GetAddedColumns()) > 0 { + this.commands = append([]*Command{this.createCommand("add", CommandOptions{})}, this.commands...) + } + if len(this.GetChangedColumns()) > 0 { + this.commands = append([]*Command{this.createCommand("change", CommandOptions{})}, this.commands...) + } + } + + this.addFluentIndexes() +} + +// 添加索引字段 +func (this *Blueprint) addFluentIndexes() { + for _, column := range this.columns { + if column.primary { + this.Primary(column.Name) + continue + } else if column.unique { + this.Unique(column.Name) + continue + } else if column.index { + this.Index(column.Name) + continue + } else if column.spatialIndex { + this.SpatialIndex(column.Name) + continue + } + + } +} + +func (this *Blueprint) addCommand(name string, options CommandOptions) (command *Command) { + command = this.createCommand(name, options) + this.commands = append(this.commands, command) + return command +} + +func (this *Blueprint) createCommand(name string, options CommandOptions) *Command { + return &Command{Type: name, CommandOptions: options} +} + +// 生成索引名称 +func (this *Blueprint) generateIndexName(typ string, columns []string) string { + return strings.ToLower(typ + "_" + strings.Join(columns, "_")) +} + +func (this *Blueprint) GetAddedColumns() []*ColumnDefinition { + return lo.Filter(this.columns, func(item *ColumnDefinition, _ int) bool { + return !item.change + }) +} + +func (this *Blueprint) GetChangedColumns() []*ColumnDefinition { + return lo.Filter(this.columns, func(item *ColumnDefinition, _ int) bool { + return item.change + }) +} + +func (this *Blueprint) GetCommands() []*Command { + return this.commands +} + +func (this *Blueprint) IsTemporary() bool { + return this.temporary +} + +func (this *Blueprint) GetTable() string { + return this.table +} + +func (this *Blueprint) GetCharset() string { + return this.charset +} + +func (this *Blueprint) GetEngine() string { + return this.engine +} + +func (this *Blueprint) GetCollation() string { + return this.collation +} + +// 命令类型 +func (this *Command) Command() string { + return this.Type +} diff --git a/schema/builder.go b/schema/builder.go new file mode 100755 index 0000000..9911773 --- /dev/null +++ b/schema/builder.go @@ -0,0 +1,95 @@ +package schema + +import ( + "git.fsdpf.net/go/db/v2" +) + +type Builder struct { + db *db.Database // The database connection instance + schema Schema // The schema grammar instance +} + +func New(db *db.Database) *Builder { + return &Builder{ + db: db, + schema: GetSchemaDialect(db), + } +} + +// 判断数据表是否存在 +func (this Builder) HasTable(table string) (bool, error) { + return this.schema.TableExists(table) +} + +// 判断数据库列是否存在 +func (this Builder) GetColumnListing(table string) (columns []string, err error) { + return this.schema.GetColumnListing(table) +} + +// 修改表 +func (this Builder) Table(table string, cb func(*Blueprint)) error { + bp := NewBlueprint(table) + cb(bp) + return this.Build(bp) +} + +// 创建表 +func (this Builder) Create(table string, cb func(*Blueprint)) error { + bp := NewBlueprint(table) + bp.Create() + cb(bp) + return this.Build(bp) +} + +// 修改表名 +func (this Builder) Rename(from, to string) error { + bp := NewBlueprint(from) + bp.Rename(to) + return this.Build(bp) +} + +// 删除表 +func (this Builder) Drop(table string) error { + bp := NewBlueprint(table) + bp.Drop() + return this.Build(bp) +} + +// 删除表, 先判断再删除 +func (this Builder) DropIfExists(table string) error { + bp := NewBlueprint(table) + bp.DropIfExists() + return this.Build(bp) +} + +// toSql +func (this Builder) ToSQL(bp *Blueprint) []string { + return bp.ToSql(this.schema) +} + +// Build execute the blueprint to build / modify the table +func (this Builder) Build(bp *Blueprint) error { + sqls := this.ToSQL(bp) + + if len(sqls) == 0 { + return nil + } + + tx, err := this.db.Begin() + + if err != nil { + return err + } + + err = tx.Wrap(func() error { + for _, sql := range sqls { + if _, err := tx.Exec(sql); err != nil { + return err + } + } + + return nil + }) + + return err +} diff --git a/schema/column_definition.go b/schema/column_definition.go new file mode 100755 index 0000000..ae9238f --- /dev/null +++ b/schema/column_definition.go @@ -0,0 +1,168 @@ +package schema + +type ColumnDefinition struct { + Type string // kind of column, string / int + Name string // name of column + ColumnOptions +} + +type ColumnOptions struct { + Length int // 字段长度 + Allowed []string // enum 选项 + Precision int // 日期时间精度 + Total int // 小数位数 + Places int // 小数精度 + rename string // 重命名 + useCurrent bool // CURRENT TIMESTAMP + after string // Place the column "after" another column (MySQL) + always bool // Used as a modifier for generatedAs() (PostgreSQL) + autoIncrement bool // Set INTEGER columns as auto-increment (primary key) + change bool // Change the column + charset string // Specify a character set for the column (MySQL) + collation string // Specify a collation for the column (MySQL/PostgreSQL/SQL Server) + comment string // Add a comment to the column (MySQL) + def any // Specify a "default" value for the column + first bool // Place the column "first" in the table (MySQL) + nullable bool // Allow NULL values to be inserted into the column + storedAs string // Create a stored generated column (MySQL) + unsigned bool // Set the INTEGER column as UNSIGNED (MySQL) + virtualAs string // Create a virtual generated column (MySQL) + unique bool // Add a unique index + primary bool // Add a primary index + index bool // Add an index + spatialIndex bool // Add a spatial index +} + +// VirtualAs Create a virtual generated column (MySQL) +func (c *ColumnDefinition) VirtualAs(as string) *ColumnDefinition { + c.virtualAs = as + return c +} + +func (c *ColumnDefinition) GetVirtualAs() string { + return c.virtualAs +} + +// StoredAs Create a stored generated column (MySQL) +func (c *ColumnDefinition) StoredAs(as string) *ColumnDefinition { + c.storedAs = as + return c +} + +func (c *ColumnDefinition) GetStoredAs() string { + return c.storedAs +} + +// Unsigned Set INTEGER columns as UNSIGNED (MySQL) +func (c *ColumnDefinition) Unsigned() *ColumnDefinition { + c.unsigned = true + return c +} + +func (c *ColumnDefinition) IsUnsigned() bool { + return c.unsigned +} + +// First Place the column "first" in the table (MySQL) +func (c *ColumnDefinition) First() *ColumnDefinition { + c.first = true + return c +} + +func (c *ColumnDefinition) IsFirst() bool { + return c.first +} + +// Default Specify a "default" value for the column +func (c *ColumnDefinition) Default(def any) *ColumnDefinition { + c.def = def + return c +} + +func (c *ColumnDefinition) GetDefault() any { + return c.def +} + +// Comment Add a comment to a column (MySQL/PostgreSQL) +func (c *ColumnDefinition) Comment(comm string) *ColumnDefinition { + c.comment = comm + return c +} + +func (c *ColumnDefinition) GetComment() string { + return c.comment +} + +// Collaction Specify a collation for the column (MySQL/PostgreSQL/SQL Server) +func (c *ColumnDefinition) Collaction(coll string) *ColumnDefinition { + c.collation = coll + return c +} + +func (c *ColumnDefinition) GetCollaction() string { + return c.collation +} + +// Charset Specify a character set for the column (MySQL) +func (c *ColumnDefinition) Charset(chars string) *ColumnDefinition { + c.charset = chars + return c +} + +func (c *ColumnDefinition) GetCharset() string { + return c.charset +} + +// AutoIncrement set INTEGER columns as auto-increment (primary key) +func (c *ColumnDefinition) AutoIncrement() *ColumnDefinition { + c.autoIncrement = true + return c +} + +func (c *ColumnDefinition) IsAutoIncrement() bool { + return c.autoIncrement +} + +// After place the column "after" another column (MySQL) +func (c *ColumnDefinition) After(column string) *ColumnDefinition { + c.after = column + return c +} + +func (c *ColumnDefinition) GetAfter() string { + return c.after +} + +// Nullable makes a column nullable +func (c *ColumnDefinition) Nullable() *ColumnDefinition { + c.nullable = true + return c +} + +func (c *ColumnDefinition) IsNullable() bool { + return c.nullable +} + +// 修改字段, 默认新增 +func (c *ColumnDefinition) Change(param ...string) *ColumnDefinition { + if len(param) > 0 && param[0] != "" { + c.rename = param[0] + } + c.change = true + return c +} + +// 时间戳 +func (c *ColumnDefinition) UseCurrent() *ColumnDefinition { + c.useCurrent = true + return c +} + +func (c *ColumnDefinition) IsUseCurrent() bool { + return c.useCurrent +} + +// 获取重命名 +func (c *ColumnDefinition) GetRename() string { + return c.rename +} diff --git a/schema/dialect/mysql/mysql.go b/schema/dialect/mysql/mysql.go new file mode 100644 index 0000000..014451b --- /dev/null +++ b/schema/dialect/mysql/mysql.go @@ -0,0 +1,279 @@ +package mysql + +import ( + "strings" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/dialect/mysql" + "git.fsdpf.net/go/db/v2/internal/sb" + "git.fsdpf.net/go/db/v2/schema" + "git.fsdpf.net/go/db/v2/sqlgen" +) + +type Mysql struct { + db *db.Database + esg sqlgen.ExpressionSQLGenerator +} + +var mysqlDefaultModifiers = []string{ + "Unsigned", "Charset", "Collate", "VirtualAs", "StoredAs", + "Nullable", "Default", "Increment", "Comment", "After", "First", +} +var mysqlSerials = []string{ + "bigInteger", "integer", "mediumInteger", "smallInteger", "tinyInteger", +} + +func (this Mysql) GetColumnListing(table string) ([]string, error) { + cols := []string{} + + err := this.db.From(db.S("information_schema").Table("columns")).Where( + db.C("table_name").Eq(table), + db.C("table_schema").Eq(db.L("database()")), + ).Pluck(&cols, "column_name") + + return cols, err +} + +func (this Mysql) TableExists(table string) (bool, error) { + result, err := this.db.From(db.S("information_schema").Table("tables")).Where(db.Ex{ + "table_name": table, + "table_type": "BASE TABLE", + "table_schema": db.L("database()"), + }).Count() + + return result > 0, err +} + +// 创建表 +func (this Mysql) CompileCreate(bp *schema.Blueprint) []string { + temporary := db.L("CREATE") + if bp.IsTemporary() { + temporary = db.L("CREATE TEMPORARY") + } + columns := strings.Join(this.getAddedColumns(bp), ",\n") + + sql := this.GenerateSQL("? TABLE ? (\n?\n)", temporary, db.T(bp.GetTable()), db.L(columns)) + + charset := bp.GetCharset() + if charset == "" { + charset = "utf8mb4" + } + + collation := bp.GetCollation() + if collation == "" { + collation = "utf8mb4_general_ci" + } + + sql += this.GenerateSQL(" default charset=? collate=?", charset, collation) + + if engine := bp.GetEngine(); engine != "" { + sql += this.GenerateSQL(" engine=?", engine) + } + + return []string{sql} +} + +// 添加字段 +func (this Mysql) CompileAdd(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("ADD COLUMN", this.getAddedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +// 修改字段 +func (this Mysql) CompileChange(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("CHANGE COLUMN", this.getChangedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +// 删除列 +func (this Mysql) CompileDropColumn(bp *schema.Blueprint) []string { + columns := []string{} + + for _, item := range bp.GetCommands() { + if item.Type != "dropColumn" { + continue + } + for _, col := range item.Columns { + columns = append(columns, this.GenerateSQL("DROP COLUMN ?", db.C(col))) + } + } + + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(strings.Join(columns, ",\n"))) + return []string{sql} +} + +// 删除表 +func (this Mysql) CompileDrop(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE ?", db.T(bp.GetTable()))} +} + +// 删除表, 先判断再删除 +func (this Mysql) CompileDropIfExists(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE IF EXISTS ?", db.T(bp.GetTable()))} +} + +// 表重命名 +func (this Mysql) CompileRename(bp *schema.Blueprint) []string { + toName := "" + commands := bp.GetCommands() + if len(commands) == 0 { + panic("new table undefined") + } + toName = bp.GetCommands()[0].To + if toName == "" { + panic("new table undefined") + } + return []string{this.GenerateSQL("RENAME TABLE ? TO ?", db.T(bp.GetTable()), db.T(toName))} +} + +func (this Mysql) addModifiers(sql string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + for _, modifier := range mysqlDefaultModifiers { + sql += this.GetColumnModifier(modifier, bp, column) + } + return sql +} + +func (this Mysql) GetColumnType(column *schema.ColumnDefinition) string { + switch column.Type { + case "char": + return this.GenerateSQL("char(?)", column.Length) + case "string": + return this.GenerateSQL("varchar(?)", column.Length) + case "text": + return "text" + case "integer": + return this.GenerateSQL("int(?)", column.Length) + case "bigInteger": + return "bigint(20)" + case "tinyInteger": + return "tinyint(1)" + case "smallInteger": + return "smallint(4)" + case "decimal": + return this.GenerateSQL("decimal(?,?)", column.Total, column.Places) + case "boolean": + return "tinyint(1)" + case "enum": + return this.GenerateSQL("enum?", column.Allowed) + case "json": + return "json" + case "binary": + return "binary" + case "datetime": + return "datetime" + case "timestamp": + return "timestamp" + case "date": + return "date" + case "time": + return "time" + case "year": + return "year" + case "uuid": + return "char(36)" + } + panic("Unsupported data type: " + column.Type) +} + +func (this Mysql) GetColumnModifier(modifier string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + switch modifier { + case "Unsigned": + if column.IsUnsigned() { + return " UNSIGNED" + } + case "Charset": + if v := column.GetCharset(); v != "" { + return this.GenerateSQL(" CHARACTER SET ?", v) + } + case "Collate": + if v := column.GetCollaction(); v != "" { + return this.GenerateSQL(" COLLATE ?", v) + } + case "VirtualAs": + if v := column.GetVirtualAs(); v != "" { + return this.GenerateSQL(" GENERATED ALWAYS AS (?) VIRTUAL", db.L(v)) + } + case "StoredAs": + if v := column.GetStoredAs(); v != "" { + return this.GenerateSQL(" GENERATED ALWAYS AS (?) STORED", db.L(v)) + } + case "Nullable": + if column.GetVirtualAs() == "" && column.GetStoredAs() == "" { + if column.IsNullable() { + return " NULL" + } + return " NOT NULL" + } + case "Default": + v := column.GetDefault() + if v == nil { + if column.IsUseCurrent() { + return " DEFAULT CURRENT_TIMESTAMP" + } + return "" + } + if column.IsUseCurrent() { + return this.GenerateSQL(" DEFAULT CURRENT_TIMESTAMP ?", v) + } + return this.GenerateSQL(" DEFAULT ?", v) + case "Increment": + if column.IsAutoIncrement() { + return " AUTO_INCREMENT PRIMARY KEY" + } + case "Comment": + if v := column.GetComment(); v != "" { + return this.GenerateSQL(" COMMENT ?", v) + } + case "After": + if v := column.GetAfter(); v != "" { + return this.GenerateSQL(" AFTER ?", v) + } + case "First": + if column.IsFirst() { + return " FIRST" + } + } + return "" +} + +// 获取新增表结构字段 +func (this Mysql) getAddedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetAddedColumns() { + sql := this.GenerateSQL("? ?", db.C(column.Name), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +// 获取修改表结构字段 +func (this Mysql) getChangedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetChangedColumns() { + name := column.Name + rename := column.Name + if column.GetRename() != "" { + rename = column.GetRename() + } + sql := this.GenerateSQL("? ? ?", db.C(name), db.C(rename), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +// 生成SQL +func (this Mysql) GenerateSQL(sql string, args ...any) string { + sb := sb.NewSQLBuilder(false) + this.esg.Generate(sb, db.L(sql, args...)) + result, _, _ := sb.ToSQL() + return result +} + +func init() { + schema.RegisterDialect("mysql", func(db *db.Database) schema.Schema { + return &Mysql{ + db: db, + esg: sqlgen.NewExpressionSQLGenerator("mysql", mysql.DialectOptions()), + } + }) +} diff --git a/schema/dialect/mysql/mysql_builder_test.go b/schema/dialect/mysql/mysql_builder_test.go new file mode 100644 index 0000000..e6677b8 --- /dev/null +++ b/schema/dialect/mysql/mysql_builder_test.go @@ -0,0 +1,109 @@ +package mysql_test + +import ( + "os" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/engine" + "git.fsdpf.net/go/db/v2/schema" + "github.com/stretchr/testify/suite" +) + +type mysqlBuilderTest struct { + suite.Suite + builder *schema.Builder +} + +func (t *mysqlBuilderTest) SetupSuite() { + db := engine.Open(map[string]engine.DBConfig{ + "test-mysql": engine.NewDBConfig("mysql", + engine.WithHost(os.Getenv("MYSQL_HOST")), + engine.WithPort(os.Getenv("MYSQL_PORT")), + engine.WithDatabase(os.Getenv("MYSQL_DB")), + engine.WithUsername(os.Getenv("MYSQL_USER")), + engine.WithPassword(os.Getenv("MYSQL_PASSWD")), + engine.WithParseTime(true), + ), + }).Connection("test-mysql") + + t.builder = schema.New(db) +} + +func (t *mysqlBuilderTest) TestHasTable() { + t.builder.HasTable("users") +} + +func (t *mysqlBuilderTest) TestGetColumnListing() { + t.builder.GetColumnListing("users") +} + +// 创建表 +func (t *mysqlBuilderTest) TestCreateTable() { + if err := t.builder.Create("users", func(table *schema.Blueprint) { + table.Charset("utf8mb4") + + table.BigIncrements("id").Comment("ID") + table.Boolean("enabled").Default("1").Comment("是否有效") + table.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + table.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + table.Timestamp("created_at").UseCurrent().Comment("创建时间") + table.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间") + table.DateTime("deleted_at").Nullable().Comment("删除时间") + }); err != nil { + t.Error(err) + } +} + +// 修改表, 添加字段 +func (t *mysqlBuilderTest) TestAddColumn() { + if err := t.builder.Table("users", func(table *schema.Blueprint) { + table.String("name", 50).Nullable().Comment("用户名") + table.TinyInteger("age").Nullable().Comment("年龄") + }); err != nil { + t.Error(err) + } +} + +// 修改表, 添加/编辑字段 +func (t *mysqlBuilderTest) TestChangeColumn() { + if err := t.builder.Table("users", func(table *schema.Blueprint) { + table.String("name", 100).Nullable().Comment("用户名").Change("username") + table.SmallInteger("age").Nullable().Comment("年龄").Change() + + table.String("nickname", 100).Nullable().Comment("昵称") + }); err != nil { + t.Error(err) + } +} + +// 修改表, 添加/编辑/删除字段 +func (t *mysqlBuilderTest) TestDropColumn() { + if err := t.builder.Table("users", func(table *schema.Blueprint) { + table.String("username", 100).Default("").Comment("用户名").Change("name") + table.Boolean("is_vip").Default(1).Comment("是否VIP") + table.DropColumn("age") + }); err != nil { + t.Error(err) + } +} + +// 重命名表 +func (t *mysqlBuilderTest) TestRename() { + if err := t.builder.Rename("users", "users_alias"); err != nil { + t.Error(err) + } +} + +// 删除表 +func (t *mysqlBuilderTest) TestDropTable() { + if err := t.builder.Drop("users_alias"); err != nil { + t.Error(err) + } +} + +// 删除表 +func (t *mysqlBuilderTest) TestDropTableIfExists() { + if err := t.builder.DropIfExists("users_alias"); err != nil { + t.Error(err) + } +} diff --git a/schema/dialect/mysql/mysql_test.go b/schema/dialect/mysql/mysql_test.go new file mode 100644 index 0000000..3005bd2 --- /dev/null +++ b/schema/dialect/mysql/mysql_test.go @@ -0,0 +1,190 @@ +package mysql_test + +import ( + "os" + "testing" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/engine" + "git.fsdpf.net/go/db/v2/schema" + "github.com/stretchr/testify/suite" + + _ "github.com/go-sql-driver/mysql" +) + +type mysqlTest struct { + suite.Suite + schema schema.Schema +} + +var ( + tableName = "entry" + + dropTable = "DROP TABLE IF EXISTS `entry`;" + + createTable = "CREATE TABLE IF NOT EXISTS `entry` (" + + "`id` INT NOT NULL AUTO_INCREMENT ," + + "`int` INT NOT NULL UNIQUE," + + "`float` FLOAT NOT NULL ," + + "`string` VARCHAR(255) NOT NULL ," + + "`time` DATETIME NOT NULL ," + + "`bool` TINYINT NOT NULL ," + + "`bytes` BLOB NOT NULL ," + + "PRIMARY KEY (`id`) );" +) + +func TestMysqlSuite(t *testing.T) { + suite.Run(t, new(mysqlTest)) +} + +func (t *mysqlTest) SetupSuite() { + db := engine.Open(map[string]engine.DBConfig{ + "test-mysql": engine.NewDBConfig("mysql", + engine.WithHost(os.Getenv("MYSQL_HOST")), + engine.WithPort(os.Getenv("MYSQL_PORT")), + engine.WithDatabase(os.Getenv("MYSQL_DB")), + engine.WithUsername(os.Getenv("MYSQL_USER")), + engine.WithPassword(os.Getenv("MYSQL_PASSWD")), + engine.WithParseTime(true), + ), + }).Connection("test-mysql") + + t.schema = schema.GetSchemaDialect(db) + + // db.Exec(dropTable) + db.Exec(createTable) +} + +func (t *mysqlTest) TestGetColumnListing() { + result, err := t.schema.GetColumnListing(tableName) + t.Require().NoError(err) + t.Require().ElementsMatch([]string{"bool", "bytes", "float", "id", "int", "string", "time"}, result) +} + +func (t *mysqlTest) TestTableExists() { + result, err := t.schema.TableExists(tableName) + t.Require().NoError(err) + t.Require().Equal(result, true) +} + +// 创建列表 +func (t *mysqlTest) TestCompileCreate() { + bp := schema.NewBlueprint("users") + bp.Create() + bp.Charset("utf8mb4") + + bp.BigIncrements("id").AutoIncrement().Comment("ID") + bp.Boolean("enabled").Default("1").Comment("是否有效") + bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + bp.Timestamp("created_at").UseCurrent().Comment("创建时间") + bp.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间") + bp.DateTime("deleted_at").Nullable().Comment("删除时间") + + sql := t.schema.CompileCreate(bp) + + t.Equal([]string{ + "CREATE TABLE `users` (\n" + + "`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',\n" + + "`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效',\n" + + "`created_user` char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '创建者',\n" + + "`owned_user` char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '拥有者',\n" + + "`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',\n" + + "`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',\n" + + "`deleted_at` datetime NULL COMMENT '删除时间'\n" + + ") default charset='utf8mb4' collate='utf8mb4_general_ci'", + }, sql) + + t.T().Log(sql) +} + +// 添加字段 +func (t *mysqlTest) TestCompileAdd() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Comment("用户名") + bp.SmallInteger("age").Default("18").Comment("年龄") + bp.Enum("sex", []string{"0", "1"}).Default("0").Comment("性别") + + sql := t.schema.CompileAdd(bp) + + t.Equal([]string{ + "ALTER TABLE `users`\n" + + "ADD COLUMN `name` varchar(50) NOT NULL COMMENT '用户名',\n" + + "ADD COLUMN `age` smallint(4) NOT NULL DEFAULT '18' COMMENT '年龄',\n" + + "ADD COLUMN `sex` enum('0', '1') NOT NULL DEFAULT '0' COMMENT '性别'", + }, sql) + + t.T().Log(sql) +} + +// 修改字段 +func (t *mysqlTest) TestCompileChange() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Change("username") + bp.SmallInteger("age").Default("19").Comment("年龄").Change() + + sql := "ALTER TABLE `users`\n" + + "CHANGE COLUMN `name` `username` varchar(50) NOT NULL,\n" + + "CHANGE COLUMN `age` `age` smallint(4) NOT NULL DEFAULT '19' COMMENT '年龄';" + + t.Equal(t.schema.CompileChange(bp), sql) + + t.T().Log(sql) +} + +// 删除字段 +func (t *mysqlTest) TestCompileDropColumn() { + bp := schema.NewBlueprint("users") + + bp.DropColumn("age", "sex") + + sql := t.schema.CompileDropColumn(bp) + + t.Equal([]string{ + "ALTER TABLE `users`\n" + + "DROP COLUMN `age`,\n" + + "DROP COLUMN `sex`", + }, sql) + + t.T().Log(sql) +} + +// 删除表 +func (t *mysqlTest) TestCompileDrop() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDrop(bp) + + t.Equal([]string{"DROP TABLE `users`"}, sql) + + t.T().Log(sql) +} + +// 删除表 +func (t *mysqlTest) TestCompileDropIfExists() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDropIfExists(bp) + t.Equal([]string{"DROP TABLE IF EXISTS `users`"}, sql) + + t.T().Log(sql) +} + +// 表重命名 +func (t *mysqlTest) TestCompileRename() { + bp := schema.NewBlueprint("users") + + bp.Rename("user") + + sql := t.schema.CompileRename(bp) + + t.Equal([]string{"RENAME TABLE `users` TO `user`"}, sql) + + t.T().Log(sql) +} diff --git a/schema/dialect/postgres/postgres.go b/schema/dialect/postgres/postgres.go new file mode 100644 index 0000000..4a71df1 --- /dev/null +++ b/schema/dialect/postgres/postgres.go @@ -0,0 +1,213 @@ +package postgres + +import ( + "strings" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/dialect/postgres" + "git.fsdpf.net/go/db/v2/internal/sb" + "git.fsdpf.net/go/db/v2/schema" + "git.fsdpf.net/go/db/v2/sqlgen" +) + +type Postgres struct { + db *db.Database + esg sqlgen.ExpressionSQLGenerator +} + +var pgDefaultModifiers = []string{ + "Nullable", "Default", "Increment", "Comment", +} +var pgSerials = []string{ + "bigInteger", "integer", "smallInteger", +} + +func (this Postgres) GetColumnListing(table string) ([]string, error) { + cols := []string{} + err := this.db.From(db.S("information_schema").Table("columns")).Where( + db.C("table_name").Eq(table), + db.C("table_schema").Eq(db.L("current_database()")), + ).Pluck(&cols, "column_name") + return cols, err +} + +func (this Postgres) TableExists(table string) (bool, error) { + result, err := this.db.From(db.S("information_schema").Table("tables")).Where(db.Ex{ + "table_name": table, + "table_schema": db.L("current_database()"), + }).Count() + return result > 0, err +} + +func (this Postgres) CompileCreate(bp *schema.Blueprint) []string { + temporary := db.L("CREATE") + if bp.IsTemporary() { + temporary = db.L("CREATE TEMPORARY") + } + columns := strings.Join(this.getAddedColumns(bp), ",\n") + sql := this.GenerateSQL("? TABLE ? (\n?\n)", temporary, db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this Postgres) CompileAdd(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("ADD COLUMN", this.getAddedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this Postgres) CompileChange(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("ALTER COLUMN", this.getChangedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this Postgres) CompileDropColumn(bp *schema.Blueprint) []string { + columns := []string{} + for _, item := range bp.GetCommands() { + if item.Type != "dropColumn" { + continue + } + for _, col := range item.Columns { + columns = append(columns, this.GenerateSQL("DROP COLUMN ?", db.C(col))) + } + } + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(strings.Join(columns, ",\n"))) + return []string{sql} +} + +func (this Postgres) CompileDrop(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE ?", db.T(bp.GetTable()))} +} + +func (this Postgres) CompileDropIfExists(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE IF EXISTS ?", db.T(bp.GetTable()))} +} + +func (this Postgres) CompileRename(bp *schema.Blueprint) []string { + toName := "" + commands := bp.GetCommands() + if len(commands) == 0 { + panic("new table undefined") + } + toName = bp.GetCommands()[0].To + if toName == "" { + panic("new table undefined") + } + return []string{this.GenerateSQL("ALTER TABLE ? RENAME TO ?", db.T(bp.GetTable()), db.T(toName))} +} + +func (this Postgres) addModifiers(sql string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + for _, modifier := range pgDefaultModifiers { + sql += this.GetColumnModifier(modifier, bp, column) + } + return sql +} + +func (this Postgres) GetColumnType(column *schema.ColumnDefinition) string { + switch column.Type { + case "char": + return this.GenerateSQL("char(?)", column.Length) + case "string": + return this.GenerateSQL("varchar(?)", column.Length) + case "text": + return "text" + case "integer": + return "integer" + case "bigInteger": + return "bigint" + case "smallInteger": + return "smallint" + case "decimal": + return this.GenerateSQL("numeric(?,?)", column.Total, column.Places) + case "boolean": + return "boolean" + case "enum": + return this.GenerateSQL("varchar(?)", column.Length) // PostgreSQL uses custom types or check constraints + case "json": + return "json" + case "binary": + return "bytea" + case "datetime": + return "timestamp without time zone" + case "timestamp": + return "timestamp with time zone" + case "date": + return "date" + case "time": + return "time" + case "year": + return "integer" + case "uuid": + return "uuid" + } + panic("Unsupported data type: " + column.Type) +} + +func (this Postgres) GetColumnModifier(modifier string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + switch modifier { + case "Nullable": + if column.IsNullable() { + return " NULL" + } + return " NOT NULL" + case "Default": + v := column.GetDefault() + if v == nil { + if column.IsUseCurrent() { + return " DEFAULT CURRENT_TIMESTAMP" + } + return "" + } + if column.IsUseCurrent() { + return this.GenerateSQL(" DEFAULT CURRENT_TIMESTAMP ?", v) + } + return this.GenerateSQL(" DEFAULT ?", v) + case "Increment": + if column.IsAutoIncrement() { + switch column.Type { + case "integer": + return " SERIAL PRIMARY KEY" + case "bigInteger": + return " BIGSERIAL PRIMARY KEY" + case "smallInteger": + return " SMALLSERIAL PRIMARY KEY" + } + } + case "Comment": + // PostgreSQL handles comments separately via COMMENT ON + return "" + } + return "" +} + +func (this Postgres) getAddedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetAddedColumns() { + sql := this.GenerateSQL("? ?", db.C(column.Name), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +func (this Postgres) getChangedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetChangedColumns() { + sql := this.GenerateSQL("? TYPE ?", db.C(column.Name), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +func (this Postgres) GenerateSQL(sql string, args ...any) string { + sb := sb.NewSQLBuilder(false) + this.esg.Generate(sb, db.L(sql, args...)) + result, _, _ := sb.ToSQL() + return result +} + +func init() { + schema.RegisterDialect("postgres", func(db *db.Database) schema.Schema { + return &Postgres{ + db: db, + esg: sqlgen.NewExpressionSQLGenerator("postgres", postgres.DialectOptions()), + } + }) +} diff --git a/schema/dialect/postgres/postgres_test.go b/schema/dialect/postgres/postgres_test.go new file mode 100644 index 0000000..562760c --- /dev/null +++ b/schema/dialect/postgres/postgres_test.go @@ -0,0 +1,182 @@ +package postgres_test + +import ( + "os" + "testing" + + "git.fsdpf.net/go/db/v2/engine" + "git.fsdpf.net/go/db/v2/schema" + "github.com/stretchr/testify/suite" + + _ "github.com/lib/pq" +) + +type postgresTest struct { + suite.Suite + schema schema.Schema +} + +var ( + tableName = "entry" + + dropTable = "DROP TABLE IF EXISTS entry;" + + createTable = "CREATE TABLE IF NOT EXISTS entry (" + + "id SERIAL PRIMARY KEY," + + "int INTEGER NOT NULL UNIQUE," + + "float REAL NOT NULL," + + "string VARCHAR(255) NOT NULL," + + "time TIMESTAMP NOT NULL," + + "bool BOOLEAN NOT NULL," + + "bytes BYTEA NOT NULL" + + ");" +) + +func TestPostgresSuite(t *testing.T) { + suite.Run(t, new(postgresTest)) +} + +func (t *postgresTest) SetupSuite() { + db := engine.Open(map[string]engine.DBConfig{ + "test-postgres": engine.NewDBConfig("postgres", + engine.WithHost(os.Getenv("POSTGRES_HOST")), + engine.WithPort(os.Getenv("POSTGRES_PORT")), + engine.WithDatabase(os.Getenv("POSTGRES_DB")), + engine.WithUsername(os.Getenv("POSTGRES_USER")), + engine.WithPassword(os.Getenv("POSTGRES_PASSWD")), + ), + }).Connection("test-postgres") + + t.schema = schema.GetSchemaDialect(db) + + db.Exec(dropTable) + db.Exec(createTable) +} + +func (t *postgresTest) TestGetColumnListing() { + result, err := t.schema.GetColumnListing(tableName) + t.Require().NoError(err) + t.Require().ElementsMatch([]string{"id", "int", "float", "string", "time", "bool", "bytes"}, result) +} + +func (t *postgresTest) TestTableExists() { + result, err := t.schema.TableExists(tableName) + t.Require().NoError(err) + t.Require().Equal(result, true) +} + +func (t *postgresTest) TestCompileCreate() { + bp := schema.NewBlueprint("users") + bp.Create() + + bp.BigIncrements("id").AutoIncrement().Comment("ID") + bp.Boolean("enabled").Default("true").Comment("是否有效") + bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + bp.Timestamp("created_at").UseCurrent().Comment("创建时间") + bp.Timestamp("updated_at").UseCurrent().Comment("更新时间") + bp.DateTime("deleted_at").Nullable().Comment("删除时间") + + sql := t.schema.CompileCreate(bp) + + t.Equal([]string{ + "CREATE TABLE \"users\" (\n" + + "\"id\" bigint NOT NULL BIGSERIAL PRIMARY KEY COMMENT 'ID',\n" + + "\"enabled\" boolean NOT NULL DEFAULT true COMMENT '是否有效',\n" + + "\"created_user\" char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '创建者',\n" + + "\"owned_user\" char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '拥有者',\n" + + "\"created_at\" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',\n" + + "\"updated_at\" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',\n" + + "\"deleted_at\" timestamp without time zone NULL COMMENT '删除时间'\n" + + ")", + }, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileAdd() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Comment("用户名") + bp.SmallInteger("age").Default("18").Comment("年龄") + bp.String("sex", 1).Default("0").Comment("性别") // Using string instead of enum + + sql := t.schema.CompileAdd(bp) + + t.Equal([]string{ + "ALTER TABLE \"users\"\n" + + "ADD COLUMN \"name\" varchar(50) NOT NULL COMMENT '用户名',\n" + + "ADD COLUMN \"age\" smallint NOT NULL DEFAULT 18 COMMENT '年龄',\n" + + "ADD COLUMN \"sex\" varchar(1) NOT NULL DEFAULT '0' COMMENT '性别'", + }, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileChange() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Change("username") + bp.SmallInteger("age").Default("19").Comment("年龄").Change() + + sql := t.schema.CompileChange(bp) + + t.Equal([]string{ + "ALTER TABLE \"users\"\n" + + "ALTER COLUMN \"name\" TYPE varchar(50) NOT NULL,\n" + + "ALTER COLUMN \"age\" TYPE smallint NOT NULL DEFAULT 19 COMMENT '年龄'", + }, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileDropColumn() { + bp := schema.NewBlueprint("users") + + bp.DropColumn("age", "sex") + + sql := t.schema.CompileDropColumn(bp) + + t.Equal([]string{ + "ALTER TABLE \"users\"\n" + + "DROP COLUMN \"age\",\n" + + "DROP COLUMN \"sex\"", + }, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileDrop() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDrop(bp) + + t.Equal([]string{"DROP TABLE \"users\""}, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileDropIfExists() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDropIfExists(bp) + t.Equal([]string{"DROP TABLE IF EXISTS \"users\""}, sql) + + t.T().Log(sql) +} + +func (t *postgresTest) TestCompileRename() { + bp := schema.NewBlueprint("users") + + bp.Rename("user") + + sql := t.schema.CompileRename(bp) + + t.Equal([]string{"ALTER TABLE \"users\" RENAME TO \"user\""}, sql) + + t.T().Log(sql) +} diff --git a/schema/dialect/sqlite3/sqlite3.go b/schema/dialect/sqlite3/sqlite3.go new file mode 100644 index 0000000..749743e --- /dev/null +++ b/schema/dialect/sqlite3/sqlite3.go @@ -0,0 +1,264 @@ +package sqlite3 + +import ( + "fmt" + "strings" + "time" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/dialect/sqlite3" + "git.fsdpf.net/go/db/v2/internal/sb" + "git.fsdpf.net/go/db/v2/schema" + "git.fsdpf.net/go/db/v2/sqlgen" +) + +var ( + sqlite3DefaultModifiers = []string{"VirtualAs", "StoredAs", "Nullable", "Default", "Increment"} + sqlite3Serials = []string{"bigInteger", "integer", "mediumInteger", "smallInteger", "tinyInteger"} +) + +type Sqlite3 struct { + db *db.Database + esg sqlgen.ExpressionSQLGenerator +} + +// 判断表是否存在 +func (this Sqlite3) TableExists(table string) (bool, error) { + result, err := this.db.From(db.T("sqlite_master")).Where(db.Ex{ + "name": table, + "type": "table", + }).Count() + return result > 0, err +} + +// 字段列 +func (this Sqlite3) GetColumnListing(table string) ([]string, error) { + cols := []struct { + Cid string `db:"cid"` + Name string `db:"name"` + Type string `db:"type"` + Notnull int32 `db:"notnull"` + DfltValue string `db:"dflt_value"` + Pk string `db:"pk"` + }{} + + err := this.db.ScanStructs(&cols, "PRAGMA table_info(entry)") + + columns := []string{} + + for _, item := range cols { + columns = append(columns, item.Name) + } + + return columns, err +} + +// 创建表 +func (this Sqlite3) CompileCreate(bp *schema.Blueprint) (sqls []string) { + temporary := db.L("CREATE") + columns := []string{} + + count := len(bp.GetAddedColumns()) - 1 + for i, column := range bp.GetAddedColumns() { + sql := this.GenerateSQL(this.addModifiers("? ? ", bp, column), + db.C(column.Name), + db.L(this.GetColumnType(column)), + ) + if v := column.GetComment(); v != "" { + if i != count { + sql += "," + } + sql += " -- " + v + } + columns = append(columns, sql) + } + sqls = append(sqls, + this.GenerateSQL("? TABLE ? (\n?\n)", + temporary, + db.T(bp.GetTable()), + db.L(strings.Join(columns, "\n")), + ), + ) + return sqls +} + +// 添加字段 +func (this Sqlite3) CompileAdd(bp *schema.Blueprint) (sqls []string) { + for _, column := range bp.GetAddedColumns() { + sqls = append(sqls, this.addModifiers( + this.GenerateSQL("ALTER TABLE ? ADD COLUMN ? ? ", + db.T(bp.GetTable()), + db.C(column.Name), + db.L(this.GetColumnType(column)), + ), bp, column), + ) + } + return sqls +} + +// 修改字段 +func (this Sqlite3) CompileChange(bp *schema.Blueprint) (sqls []string) { + for _, column := range bp.GetChangedColumns() { + name := column.Name + rename := column.Name + old_name := fmt.Sprintf("%s_%d", name, time.Now().UnixNano()) + + sql := this.GenerateSQL("ALTER TABLE ? ADD COLUMN ? ? ", db.T(bp.GetTable()), db.C(old_name), db.L(this.GetColumnType(column))) + sqls = append(sqls, this.addModifiers(sql, bp, column)) + if name := column.GetRename(); name != "" { + rename = name + } + sqls = append(sqls, this.GenerateSQL("UPDATE ? SET ?=?", db.T(bp.GetTable()), db.C(old_name), db.C(name))) + sqls = append(sqls, this.GenerateSQL("ALTER TABLE ? DROP COLUMN ?", db.T(bp.GetTable()), db.C(name))) + sqls = append(sqls, this.GenerateSQL("ALTER TABLE ? RENAME COLUMN ? TO ?", db.T(bp.GetTable()), db.T(old_name), db.T(rename))) + } + return sqls +} + +// 删除表 +func (this Sqlite3) CompileDrop(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE ?", db.T(bp.GetTable()))} +} + +// 删除表, 先判断再删除 +func (this Sqlite3) CompileDropIfExists(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE IF EXISTS ?", db.T(bp.GetTable()))} +} + +// 删除表, 删除列 +func (this Sqlite3) CompileDropColumn(bp *schema.Blueprint) (sqls []string) { + for _, item := range bp.GetCommands() { + if item.Type != "dropColumn" { + continue + } + for _, col := range item.Columns { + sqls = append(sqls, this.GenerateSQL("ALTER TABLE ? DROP COLUMN ?", db.T(bp.GetTable()), db.C(col))) + } + } + return sqls +} + +// 表重命名 +func (this Sqlite3) CompileRename(bp *schema.Blueprint) []string { + toName := "" + commands := bp.GetCommands() + if len(commands) == 0 { + panic("new table undefined") + } + toName = bp.GetCommands()[0].To + if toName == "" { + panic("new table undefined") + } + return []string{this.GenerateSQL("RENAME TABLE ? TO ?", db.T(bp.GetTable()), db.T(toName))} +} + +func (this Sqlite3) GetColumnModifier(modifier string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + switch modifier { + case "Collate": + if v := column.GetCollaction(); v != "" { + return this.GenerateSQL(" COLLATE ?", v) + } + case "VirtualAs": + if v := column.GetVirtualAs(); v != "" { + return this.GenerateSQL(" GENERATED ALWAYS AS (?) VIRTUAL", db.L(v)) + } + case "StoredAs": + if v := column.GetStoredAs(); v != "" { + return this.GenerateSQL(" GENERATED ALWAYS AS (?) STORED", db.L(v)) + } + case "Nullable": + if column.GetVirtualAs() == "" && column.GetStoredAs() == "" { + if column.IsNullable() { + return "NULL" + } + return "NOT NULL" + } + case "Default": + if column.IsUseCurrent() { + return " DEFAULT CURRENT_TIMESTAMP" + } + v := column.GetDefault() + if v == nil { + return "" + } + return this.GenerateSQL(" DEFAULT ?", v) + case "Increment": + if column.IsAutoIncrement() { + return " PRIMARY KEY AUTOINCREMENT" + } + case "Comment": + if v := column.GetComment(); v != "" { + return this.GenerateSQL(" COMMENT ?", v) + } + } + return "" +} + +func (this Sqlite3) GetColumnType(column *schema.ColumnDefinition) string { + if column.IsAutoIncrement() { + return "INTEGER" + } + switch column.Type { + case "char": + return this.GenerateSQL("char(?)", column.Length) + case "string": + return this.GenerateSQL("varchar(?)", column.Length) + case "text": + return "text" + case "integer": + return this.GenerateSQL("INTEGER(?)", column.Length) + case "bigInteger": + return "bigint(20)" + case "tinyInteger": + return "tinyint(1)" + case "smallInteger": + return "smallint(4)" + case "decimal": + return this.GenerateSQL("numeric(?,?)", column.Total, column.Places) + case "boolean": + return "tinyint(1)" + case "enum": + return this.GenerateSQL("varchar check(? in?)", column.Name, column.Allowed) + case "json": + return "json" + case "binary": + return "binary" + case "datetime": + return "datetime" + case "timestamp": + return "timestamp" + case "date": + return "date" + case "time": + return "time" + case "year": + return "year" + case "uuid": + return "char(36)" + } + panic("Unsupported data type: " + column.Type) +} + +func (this Sqlite3) addModifiers(sql string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + for _, modifier := range sqlite3DefaultModifiers { + sql += this.GetColumnModifier(modifier, bp, column) + } + return sql +} + +// 生成SQL +func (this Sqlite3) GenerateSQL(sql string, args ...any) string { + sb := sb.NewSQLBuilder(false) + this.esg.Generate(sb, db.L(sql, args...)) + result, _, _ := sb.ToSQL() + return result +} + +func init() { + schema.RegisterDialect("sqlite3", func(db *db.Database) schema.Schema { + return &Sqlite3{ + db: db, + esg: sqlgen.NewExpressionSQLGenerator("sqlite3", sqlite3.DialectOptions()), + } + }) +} diff --git a/schema/dialect/sqlite3/sqlite3_test.go b/schema/dialect/sqlite3/sqlite3_test.go new file mode 100644 index 0000000..c5c1122 --- /dev/null +++ b/schema/dialect/sqlite3/sqlite3_test.go @@ -0,0 +1,192 @@ +package sqlite3_test + +import ( + "os" + "testing" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/engine" + "git.fsdpf.net/go/db/v2/schema" + "github.com/stretchr/testify/suite" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + tableName = "entry" + + dropTable = "DROP TABLE IF EXISTS `entry`;" + + createTable = "CREATE TABLE IF NOT EXISTS `entry` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT," + + "`int` INT NOT NULL ," + + "`float` FLOAT NOT NULL ," + + "`string` VARCHAR(255) NOT NULL ," + + "`time` DATETIME NOT NULL ," + + "`bool` TINYINT NOT NULL ," + + "`bytes` BLOB NOT NULL" + + ");" +) + +type sqlite3Test struct { + suite.Suite + schema schema.Schema +} + +func TestSqlite3Suite(t *testing.T) { + suite.Run(t, new(sqlite3Test)) +} + +func (t *sqlite3Test) SetupSuite() { + db := engine.Open(map[string]engine.DBConfig{ + "test-sqlite3": engine.NewDBConfig("sqlite3", + engine.WithSQLiteFile(os.Getenv("DB_FILE")), + ), + }).Connection("test-sqlite3") + + t.schema = schema.GetSchemaDialect(db) + + // db.Exec(dropTable) + if _, err := db.Exec(createTable); err != nil { + t.Require().NoError(err) + } +} + +func (t *sqlite3Test) TestTableExists() { + result, err := t.schema.TableExists(tableName) + t.Require().NoError(err) + t.Require().Equal(result, true) +} + +func (t *sqlite3Test) TestGetColumnListing() { + result, err := t.schema.GetColumnListing(tableName) + t.Require().NoError(err) + t.Require().ElementsMatch([]string{"bool", "bytes", "float", "id", "int", "string", "time"}, result) +} + +// 创建列表 +func (t *sqlite3Test) TestCompileCreate() { + bp := schema.NewBlueprint("users") + bp.Create() + bp.Charset("utf8mb4") + + bp.BigIncrements("id").AutoIncrement().Comment("ID") + bp.Boolean("enabled").Default("1").Comment("是否有效") + bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + bp.Timestamp("created_at").UseCurrent().Comment("创建时间") + bp.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间") + bp.DateTime("deleted_at").Nullable().Comment("删除时间") + + sql := t.schema.CompileCreate(bp) + + t.Equal([]string{ + "CREATE TABLE `users` (\n" + + "`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, -- ID\n" + + "`enabled` tinyint(1) NOT NULL DEFAULT '1', -- 是否有效\n" + + "`created_user` char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', -- 创建者\n" + + "`owned_user` char(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', -- 拥有者\n" + + "`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间\n" + + "`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间\n" + + "`deleted_at` datetime NULL -- 删除时间\n" + + ")", + }, sql) + + t.T().Log(sql) +} + +// 添加字段 +func (t *sqlite3Test) TestCompileAdd() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Comment("用户名") + bp.SmallInteger("age").Default("18").Comment("年龄") + bp.Enum("sex", []string{"0", "1"}).Default("0").Comment("性别") + + sql := t.schema.CompileAdd(bp) + + t.Equal([]string{ + "ALTER TABLE `users` ADD COLUMN `name` varchar(50) NOT NULL", + "ALTER TABLE `users` ADD COLUMN `age` smallint(4) NOT NULL DEFAULT '18'", + "ALTER TABLE `users` ADD COLUMN `sex` varchar check('sex' in('0', '1')) NOT NULL DEFAULT '0'", + }, sql) + + t.T().Log(sql) +} + +// 修改字段 +func (t *sqlite3Test) TestCompileChange() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Change("username") + bp.SmallInteger("age").Default("19").Comment("年龄").Change() + + sql := t.schema.CompileChange(bp) + t.Equal(len([]string{ + "ALTER TABLE `users` ADD COLUMN `name_1742738970482105700` varchar(50) NOT NULL", + "UPDATE `users` SET `name_1742738970482105700`=`name`", + "ALTER TABLE `users` DROP COLUMN `name`", + "ALTER TABLE `users` RENAME COLUMN `name_1742738970482105700` TO `username`", + "ALTER TABLE `users` ADD COLUMN `age_1742738970482151800` smallint(4) NOT NULL DEFAULT '19'", + "UPDATE `users` SET `age_1742738970482151800`=`age`", + "ALTER TABLE `users` DROP COLUMN `age`", + "ALTER TABLE `users` RENAME COLUMN `age_1742738970482151800` TO `age`", + }), len(sql)) + + t.T().Log(sql) +} + +// 删除字段 +func (t *sqlite3Test) TestCompileDropColumn() { + bp := schema.NewBlueprint("users") + + bp.DropColumn("age", "sex") + + sql := t.schema.CompileDropColumn(bp) + + t.Equal([]string{ + "ALTER TABLE `users` DROP COLUMN `age`", + "ALTER TABLE `users` DROP COLUMN `sex`", + }, sql) + + t.T().Log(sql) +} + +// 删除表 +func (t *sqlite3Test) TestCompileDrop() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDrop(bp) + + t.Equal([]string{"DROP TABLE `users`"}, sql) + + t.T().Log(sql) +} + +// 删除表 +func (t *sqlite3Test) TestCompileDropIfExists() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDropIfExists(bp) + + t.Equal([]string{"DROP TABLE IF EXISTS `users`"}, sql) + + t.T().Log(sql) +} + +// 表重命名 +func (t *sqlite3Test) TestCompileRename() { + bp := schema.NewBlueprint("users") + + bp.Rename("user") + + sql := t.schema.CompileRename(bp) + + t.Equal([]string{"RENAME TABLE `users` TO `user`"}, sql) + + t.T().Log(sql) +} diff --git a/schema/dialect/sqlserver/sqlserver.go b/schema/dialect/sqlserver/sqlserver.go new file mode 100644 index 0000000..607a49c --- /dev/null +++ b/schema/dialect/sqlserver/sqlserver.go @@ -0,0 +1,201 @@ +package sqlserver + +import ( + "strings" + + "git.fsdpf.net/go/db/v2" + "git.fsdpf.net/go/db/v2/dialect/sqlserver" + "git.fsdpf.net/go/db/v2/internal/sb" + "git.fsdpf.net/go/db/v2/schema" + "git.fsdpf.net/go/db/v2/sqlgen" +) + +type SQLServer struct { + db *db.Database + esg sqlgen.ExpressionSQLGenerator +} + +var sqlServerDefaultModifiers = []string{ + "Nullable", "Default", "Increment", "Comment", +} + +func (this SQLServer) GetColumnListing(table string) ([]string, error) { + cols := []string{} + err := this.db.From(db.S("INFORMATION_SCHEMA").Table("COLUMNS")).Where( + db.C("TABLE_NAME").Eq(table), + db.C("TABLE_SCHEMA").Eq(db.L("SCHEMA_NAME()")), + ).Pluck(&cols, "COLUMN_NAME") + return cols, err +} + +func (this SQLServer) TableExists(table string) (bool, error) { + result, err := this.db.From(db.S("INFORMATION_SCHEMA").Table("TABLES")).Where(db.Ex{ + "TABLE_NAME": table, + "TABLE_TYPE": "BASE TABLE", + "TABLE_SCHEMA": db.L("SCHEMA_NAME()"), + }).Count() + return result > 0, err +} + +func (this SQLServer) CompileCreate(bp *schema.Blueprint) []string { + temporary := db.L("CREATE") + if bp.IsTemporary() { + temporary = db.L("CREATE") + } + columns := strings.Join(this.getAddedColumns(bp), ",\n") + sql := this.GenerateSQL("? TABLE ? (\n?\n)", temporary, db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this SQLServer) CompileAdd(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("ADD", this.getAddedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this SQLServer) CompileChange(bp *schema.Blueprint) []string { + columns := strings.Join(schema.PrefixArray("ALTER COLUMN", this.getChangedColumns(bp)), ",\n") + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(columns)) + return []string{sql} +} + +func (this SQLServer) CompileDropColumn(bp *schema.Blueprint) []string { + columns := []string{} + for _, item := range bp.GetCommands() { + if item.Type != "dropColumn" { + continue + } + for _, col := range item.Columns { + columns = append(columns, this.GenerateSQL("DROP COLUMN ?", db.C(col))) + } + } + sql := this.GenerateSQL("ALTER TABLE ?\n?", db.T(bp.GetTable()), db.L(strings.Join(columns, ",\n"))) + return []string{sql} +} + +func (this SQLServer) CompileDrop(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE ?", db.T(bp.GetTable()))} +} + +func (this SQLServer) CompileDropIfExists(bp *schema.Blueprint) []string { + return []string{this.GenerateSQL("DROP TABLE IF EXISTS ?", db.T(bp.GetTable()))} +} + +func (this SQLServer) CompileRename(bp *schema.Blueprint) []string { + toName := "" + commands := bp.GetCommands() + if len(commands) == 0 { + panic("new table undefined") + } + toName = bp.GetCommands()[0].To + if toName == "" { + panic("new table undefined") + } + return []string{this.GenerateSQL("EXEC sp_rename ?, ?", db.T(bp.GetTable()), db.T(toName))} +} + +func (this SQLServer) addModifiers(sql string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + for _, modifier := range sqlServerDefaultModifiers { + sql += this.GetColumnModifier(modifier, bp, column) + } + return sql +} + +func (this SQLServer) GetColumnType(column *schema.ColumnDefinition) string { + switch column.Type { + case "char": + return this.GenerateSQL("CHAR(?)", column.Length) + case "string": + return this.GenerateSQL("NVARCHAR(?)", column.Length) + case "text": + return "NVARCHAR(MAX)" + case "integer": + return "INT" + case "bigInteger": + return "BIGINT" + case "smallInteger": + return "SMALLINT" + case "decimal": + return this.GenerateSQL("DECIMAL(?,?)", column.Total, column.Places) + case "boolean": + return "BIT" + case "enum": + return this.GenerateSQL("NVARCHAR(?)", column.Length) // SQL Server doesn't have native enum + case "json": + return "NVARCHAR(MAX)" // SQL Server 2016+ has JSON support + case "binary": + return "VARBINARY(MAX)" + case "datetime": + return "DATETIME2" + case "timestamp": + return "DATETIME2" + case "date": + return "DATE" + case "time": + return "TIME" + case "year": + return "SMALLINT" + case "uuid": + return "UNIQUEIDENTIFIER" + } + panic("Unsupported data type: " + column.Type) +} + +func (this SQLServer) GetColumnModifier(modifier string, bp *schema.Blueprint, column *schema.ColumnDefinition) string { + switch modifier { + case "Nullable": + if column.IsNullable() { + return " NULL" + } + return " NOT NULL" + case "Default": + v := column.GetDefault() + if v == nil { + if column.IsUseCurrent() { + return " DEFAULT GETDATE()" + } + return "" + } + return this.GenerateSQL(" DEFAULT ?", v) + case "Increment": + if column.IsAutoIncrement() { + return " IDENTITY(1,1) PRIMARY KEY" + } + case "Comment": + // SQL Server handles comments via extended properties + return "" + } + return "" +} + +func (this SQLServer) getAddedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetAddedColumns() { + sql := this.GenerateSQL("? ?", db.C(column.Name), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +func (this SQLServer) getChangedColumns(bp *schema.Blueprint) (columns []string) { + for _, column := range bp.GetChangedColumns() { + sql := this.GenerateSQL("? ?", db.C(column.Name), db.L(this.GetColumnType(column))) + columns = append(columns, this.addModifiers(sql, bp, column)) + } + return +} + +func (this SQLServer) GenerateSQL(sql string, args ...any) string { + sb := sb.NewSQLBuilder(false) + this.esg.Generate(sb, db.L(sql, args...)) + result, _, _ := sb.ToSQL() + return result +} + +func init() { + schema.RegisterDialect("sqlserver", func(db *db.Database) schema.Schema { + return &SQLServer{ + db: db, + esg: sqlgen.NewExpressionSQLGenerator("sqlserver", sqlserver.DialectOptions()), + } + }) +} diff --git a/schema/dialect/sqlserver/sqlserver_test.go b/schema/dialect/sqlserver/sqlserver_test.go new file mode 100644 index 0000000..d76a785 --- /dev/null +++ b/schema/dialect/sqlserver/sqlserver_test.go @@ -0,0 +1,182 @@ +package sqlserver_test + +import ( + "os" + "testing" + + "git.fsdpf.net/go/db/v2/engine" + "git.fsdpf.net/go/db/v2/schema" + "github.com/stretchr/testify/suite" + + _ "github.com/denisenkom/go-mssqldb" +) + +type sqlServerTest struct { + suite.Suite + schema schema.Schema +} + +var ( + tableName = "entry" + + dropTable = "IF OBJECT_ID('entry', 'U') IS NOT NULL DROP TABLE entry;" + + createTable = "CREATE TABLE entry (" + + "id INT NOT NULL IDENTITY(1,1) PRIMARY KEY," + + "int INT NOT NULL UNIQUE," + + "float REAL NOT NULL," + + "string NVARCHAR(255) NOT NULL," + + "time DATETIME2 NOT NULL," + + "bool BIT NOT NULL," + + "bytes VARBINARY(MAX) NOT NULL" + + ");" +) + +func TestSQLServerSuite(t *testing.T) { + suite.Run(t, new(sqlServerTest)) +} + +func (t *sqlServerTest) SetupSuite() { + db := engine.Open(map[string]engine.DBConfig{ + "test-sqlserver": engine.NewDBConfig("sqlserver", + engine.WithHost(os.Getenv("SQLSERVER_HOST")), + engine.WithPort(os.Getenv("SQLSERVER_PORT")), + engine.WithDatabase(os.Getenv("SQLSERVER_DB")), + engine.WithUsername(os.Getenv("SQLSERVER_USER")), + engine.WithPassword(os.Getenv("SQLSERVER_PASSWD")), + ), + }).Connection("test-sqlserver") + + t.schema = schema.GetSchemaDialect(db) + + db.Exec(dropTable) + db.Exec(createTable) +} + +func (t *sqlServerTest) TestGetColumnListing() { + result, err := t.schema.GetColumnListing(tableName) + t.Require().NoError(err) + t.Require().ElementsMatch([]string{"id", "int", "float", "string", "time", "bool", "bytes"}, result) +} + +func (t *sqlServerTest) TestTableExists() { + result, err := t.schema.TableExists(tableName) + t.Require().NoError(err) + t.Require().Equal(result, true) +} + +func (t *sqlServerTest) TestCompileCreate() { + bp := schema.NewBlueprint("users") + bp.Create() + + bp.BigIncrements("id").AutoIncrement().Comment("ID") + bp.Boolean("enabled").Default("1").Comment("是否有效") + bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者") + bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者") + bp.Timestamp("created_at").UseCurrent().Comment("创建时间") + bp.Timestamp("updated_at").UseCurrent().Comment("更新时间") + bp.DateTime("deleted_at").Nullable().Comment("删除时间") + + sql := t.schema.CompileCreate(bp) + + t.Equal([]string{ + "CREATE TABLE [users] (\n" + + "[id] BIGINT NOT NULL IDENTITY(1,1) PRIMARY KEY COMMENT 'ID',\n" + + "[enabled] BIT NOT NULL DEFAULT 1 COMMENT '是否有效',\n" + + "[created_user] CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '创建者',\n" + + "[owned_user] CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000' COMMENT '拥有者',\n" + + "[created_at] DATETIME2 NOT NULL DEFAULT GETDATE() COMMENT '创建时间',\n" + + "[updated_at] DATETIME2 NOT NULL DEFAULT GETDATE() COMMENT '更新时间',\n" + + "[deleted_at] DATETIME2 NULL COMMENT '删除时间'\n" + + ")", + }, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileAdd() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Comment("用户名") + bp.SmallInteger("age").Default("18").Comment("年龄") + bp.String("sex", 1).Default("0").Comment("性别") + + sql := t.schema.CompileAdd(bp) + + t.Equal([]string{ + "ALTER TABLE [users]\n" + + "ADD [name] NVARCHAR(50) NOT NULL COMMENT '用户名',\n" + + "ADD [age] SMALLINT NOT NULL DEFAULT 18 COMMENT '年龄',\n" + + "ADD [sex] NVARCHAR(1) NOT NULL DEFAULT '0' COMMENT '性别'", + }, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileChange() { + bp := schema.NewBlueprint("users") + + bp.String("name", 50).Change("username") + bp.SmallInteger("age").Default("19").Comment("年龄").Change() + + sql := t.schema.CompileChange(bp) + + t.Equal([]string{ + "ALTER TABLE [users]\n" + + "ALTER COLUMN [name] NVARCHAR(50) NOT NULL,\n" + + "ALTER COLUMN [age] SMALLINT NOT NULL DEFAULT 19 COMMENT '年龄'", + }, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileDropColumn() { + bp := schema.NewBlueprint("users") + + bp.DropColumn("age", "sex") + + sql := t.schema.CompileDropColumn(bp) + + t.Equal([]string{ + "ALTER TABLE [users]\n" + + "DROP COLUMN [age],\n" + + "DROP COLUMN [sex]", + }, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileDrop() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDrop(bp) + + t.Equal([]string{"DROP TABLE [users]"}, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileDropIfExists() { + bp := schema.NewBlueprint("users") + + bp.Drop() + + sql := t.schema.CompileDropIfExists(bp) + t.Equal([]string{"DROP TABLE IF EXISTS [users]"}, sql) + + t.T().Log(sql) +} + +func (t *sqlServerTest) TestCompileRename() { + bp := schema.NewBlueprint("users") + + bp.Rename("user") + + sql := t.schema.CompileRename(bp) + + t.Equal([]string{"EXEC sp_rename [users], [user]"}, sql) + + t.T().Log(sql) +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..1be2309 --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,59 @@ +package schema + +import ( + "fmt" + "strings" + "sync" + + "git.fsdpf.net/go/db/v2" +) + +type Schema interface { + // 判断表是否存在 + TableExists(table string) (bool, error) + // 字段列 + GetColumnListing(table string) ([]string, error) + // 创建表 + CompileCreate(bp *Blueprint) []string + // 添加字段 + CompileAdd(bp *Blueprint) []string + // 修改字段 + CompileChange(bp *Blueprint) []string + // 删除表 + CompileDrop(bp *Blueprint) []string + // 删除表, 先判断再删除 + CompileDropIfExists(bp *Blueprint) []string + // 删除表, 删除列 + CompileDropColumn(bp *Blueprint) []string + // 表重命名 + CompileRename(bp *Blueprint) []string + // 生成SQL + GenerateSQL(sql string, args ...any) string +} + +var ( + dialects = make(map[string]func(db *db.Database) Schema) + dialectsMu sync.RWMutex +) + +func RegisterDialect(name string, sc func(db *db.Database) Schema) { + dialectsMu.Lock() + defer dialectsMu.Unlock() + lowerName := strings.ToLower(name) + dialects[lowerName] = sc +} + +func DeregisterDialect(name string) { + dialectsMu.Lock() + defer dialectsMu.Unlock() + delete(dialects, strings.ToLower(name)) +} + +func GetSchemaDialect(db *db.Database) Schema { + name := strings.ToLower(db.Dialect()) + if d, ok := dialects[name]; ok { + return d(db) + } + + panic(fmt.Sprintf("Unsupported driver: %s", name)) +} diff --git a/schema/util.go b/schema/util.go new file mode 100755 index 0000000..ac4faa8 --- /dev/null +++ b/schema/util.go @@ -0,0 +1,26 @@ +package schema + +import ( + "strings" + + "github.com/samber/lo" +) + +func PrefixArray(prefix string, values []string) (items []string) { + for _, value := range values { + items = append(items, prefix+" "+value) + } + return items +} + +func QuoteString(value any) string { + switch v := value.(type) { + case []string: + return strings.Join(lo.Map(v, func(item string, _ int) string { + return "'" + item + "'" + }), ", ") + case string: + return "'" + v + "'" + } + return "" +}