From e06148de95ff6b37e72a54f2daccb11e4c0f8cda Mon Sep 17 00:00:00 2001 From: gzdaijie Date: Sun, 8 Mar 2020 22:07:06 +0800 Subject: [PATCH] add day4-day7 --- README.md | 14 +- gee-orm/day5-hooks/session/hooks.go | 13 +- gee-orm/day6-transaction/session/hooks.go | 13 +- gee-orm/day7-migrate/geeorm_test.go | 2 +- gee-orm/day7-migrate/session/hooks.go | 13 +- gee-orm/doc/geeorm-day2.md | 2 +- gee-orm/doc/geeorm-day3.md | 28 ++- gee-orm/doc/geeorm-day4.md | 266 +++++++++++++++++++++- gee-orm/doc/geeorm-day5.md | 136 ++++++++++- gee-orm/doc/geeorm-day6.md | 257 ++++++++++++++++++++- gee-orm/doc/geeorm-day7.md | 150 +++++++++++- gee-orm/doc/geeorm.md | 14 +- 12 files changed, 858 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 5a9a75a..a85bc69 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ gorm 准备推出完全重写的 v2 版本(目前还在开发中),相对 gorm-v1 来说,xorm 的设计更容易理解,所以 geeorm 接口设计上主要参考了 xorm,一些细节实现上参考了 gorm。 -- 第一天:database/sql 基础 | [Code](gee-orm/day1-database-sql) -- 第二天:对象表结构映射 | [Code](gee-orm/day2-reflect-schema) -- 第三天:插入/查询记录 | [Code](gee-orm/day3-save-query) -- 第四天:链式操作与更新删除 | [Code](gee-orm/day4-chain-operation) -- 第五天:实现钩子(Hooks) | [Code](gee-orm/day5-hooks) -- 第六天:支持事务(Transaction) | [Code](gee-orm/day6-transaction) -- 第七天:数据库迁移(Migrate) | [Code](gee-orm/day7-migrate) +- 第一天:[database/sql 基础](https://geektutu.com/post/geeorm-day1.html) | [Code](gee-orm/day1-database-sql) +- 第二天:[对象表结构映射](https://geektutu.com/post/geeorm-day2.html) | [Code](gee-orm/day2-reflect-schema) +- 第三天:[记录新增和查询](https://geektutu.com/post/geeorm-day3.html) | [Code](gee-orm/day3-save-query) +- 第四天:[链式操作与更新删除](https://geektutu.com/post/geeorm-day4.html) | [Code](gee-orm/day4-chain-operation) +- 第五天:[实现钩子(Hooks)](https://geektutu.com/post/geeorm-day5.html) | [Code](gee-orm/day5-hooks) +- 第六天:[支持事务(Transaction)](https://geektutu.com/post/geeorm-day6.html) | [Code](gee-orm/day6-transaction) +- 第七天:[数据库迁移(Migrate)](https://geektutu.com/post/geeorm-day7.html) | [Code](gee-orm/day7-migrate) ### WebAssembly 使用示例 diff --git a/gee-orm/day5-hooks/session/hooks.go b/gee-orm/day5-hooks/session/hooks.go index 12462a4..d73c3c2 100644 --- a/gee-orm/day5-hooks/session/hooks.go +++ b/gee-orm/day5-hooks/session/hooks.go @@ -1,13 +1,16 @@ package session -import "reflect" +import ( + "geeorm/log" + "reflect" +) // Hooks constants const ( BeforeQuery = "BeforeQuery" AfterQuery = "AfterQuery" BeforeUpdate = "BeforeUpdate" - AfterUpdate = "AfterUpate" + AfterUpdate = "AfterUpdate" BeforeDelete = "BeforeDelete" AfterDelete = "AfterDelete" BeforeInsert = "BeforeInsert" @@ -15,7 +18,7 @@ const ( ) // CallMethod calls the registered hooks -func (s *Session) CallMethod(method string, value interface{}) error { +func (s *Session) CallMethod(method string, value interface{}) { fm := reflect.ValueOf(s.RefTable().Model).MethodByName(method) if value != nil { fm = reflect.ValueOf(value).MethodByName(method) @@ -24,9 +27,9 @@ func (s *Session) CallMethod(method string, value interface{}) error { if fm.IsValid() { if v := fm.Call(param); len(v) > 0 { if err, ok := v[0].Interface().(error); ok { - return err + log.Error(err) } } } - return nil + return } diff --git a/gee-orm/day6-transaction/session/hooks.go b/gee-orm/day6-transaction/session/hooks.go index 12462a4..d73c3c2 100644 --- a/gee-orm/day6-transaction/session/hooks.go +++ b/gee-orm/day6-transaction/session/hooks.go @@ -1,13 +1,16 @@ package session -import "reflect" +import ( + "geeorm/log" + "reflect" +) // Hooks constants const ( BeforeQuery = "BeforeQuery" AfterQuery = "AfterQuery" BeforeUpdate = "BeforeUpdate" - AfterUpdate = "AfterUpate" + AfterUpdate = "AfterUpdate" BeforeDelete = "BeforeDelete" AfterDelete = "AfterDelete" BeforeInsert = "BeforeInsert" @@ -15,7 +18,7 @@ const ( ) // CallMethod calls the registered hooks -func (s *Session) CallMethod(method string, value interface{}) error { +func (s *Session) CallMethod(method string, value interface{}) { fm := reflect.ValueOf(s.RefTable().Model).MethodByName(method) if value != nil { fm = reflect.ValueOf(value).MethodByName(method) @@ -24,9 +27,9 @@ func (s *Session) CallMethod(method string, value interface{}) error { if fm.IsValid() { if v := fm.Call(param); len(v) > 0 { if err, ok := v[0].Interface().(error); ok { - return err + log.Error(err) } } } - return nil + return } diff --git a/gee-orm/day7-migrate/geeorm_test.go b/gee-orm/day7-migrate/geeorm_test.go index b9656c5..4ccacf7 100644 --- a/gee-orm/day7-migrate/geeorm_test.go +++ b/gee-orm/day7-migrate/geeorm_test.go @@ -74,7 +74,7 @@ func TestEngine_Migrate(t *testing.T) { defer engine.Close() s := engine.NewSession() _, _ = s.Raw("DROP TABLE IF EXISTS User;").Exec() - _, _ = s.Raw("CREATE TABLE User(Name text, XXX integer);").Exec() + _, _ = s.Raw("CREATE TABLE User(Name text PRIMARY KEY, XXX integer);").Exec() _, _ = s.Raw("INSERT INTO User(`Name`) values (?), (?)", "Tom", "Sam").Exec() engine.Migrate(&User{}) diff --git a/gee-orm/day7-migrate/session/hooks.go b/gee-orm/day7-migrate/session/hooks.go index 12462a4..d73c3c2 100644 --- a/gee-orm/day7-migrate/session/hooks.go +++ b/gee-orm/day7-migrate/session/hooks.go @@ -1,13 +1,16 @@ package session -import "reflect" +import ( + "geeorm/log" + "reflect" +) // Hooks constants const ( BeforeQuery = "BeforeQuery" AfterQuery = "AfterQuery" BeforeUpdate = "BeforeUpdate" - AfterUpdate = "AfterUpate" + AfterUpdate = "AfterUpdate" BeforeDelete = "BeforeDelete" AfterDelete = "AfterDelete" BeforeInsert = "BeforeInsert" @@ -15,7 +18,7 @@ const ( ) // CallMethod calls the registered hooks -func (s *Session) CallMethod(method string, value interface{}) error { +func (s *Session) CallMethod(method string, value interface{}) { fm := reflect.ValueOf(s.RefTable().Model).MethodByName(method) if value != nil { fm = reflect.ValueOf(value).MethodByName(method) @@ -24,9 +27,9 @@ func (s *Session) CallMethod(method string, value interface{}) error { if fm.IsValid() { if v := fm.Call(param); len(v) > 0 { if err, ok := v[0].Interface().(error); ok { - return err + log.Error(err) } } } - return nil + return } diff --git a/gee-orm/doc/geeorm-day2.md b/gee-orm/doc/geeorm-day2.md index 60f02f5..9287a51 100644 --- a/gee-orm/doc/geeorm-day2.md +++ b/gee-orm/doc/geeorm-day2.md @@ -22,7 +22,7 @@ github: https://github.com/geektutu/7days-golang - 使用 dialect 隔离不同数据库之间的差异,便于扩展。 - 使用反射(reflect)获取任意 struct 对象的名称和字段,映射为数据中的表。 -- 数据库表的创建(create)、删除(drop)。 +- 数据库表的创建(create)、删除(drop)。**代码约150行** ## 1 Dialect diff --git a/gee-orm/doc/geeorm-day3.md b/gee-orm/doc/geeorm-day3.md index 26790de..ff089e1 100644 --- a/gee-orm/doc/geeorm-day3.md +++ b/gee-orm/doc/geeorm-day3.md @@ -1,5 +1,5 @@ --- -title: 动手写ORM框架 - GeeORM第三天 插入和查询记录 +title: 动手写ORM框架 - GeeORM第三天 记录新增和查询 date: 2020-03-08 01:00:00 description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。实现新增(insert)记录的功能;使用反射(reflect)将数据库的记录转换为对应的结构体实例,实现查询(select)功能。 tags: @@ -22,7 +22,7 @@ published: false 本文是[7天用Go从零实现ORM框架GeeORM](https://geektutu.com/post/geeorm.html)的第三篇。 - 实现新增(insert)记录的功能。 -- 使用反射(reflect)将数据库的记录转换为对应的结构体实例,实现查询(select)功能。 +- 使用反射(reflect)将数据库的记录转换为对应的结构体实例,实现查询(select)功能。**代码约150行** ## 1 Clause 构造 SQL 语句 @@ -202,6 +202,26 @@ func TestClause_Build(t *testing.T) { ## 2 实现 Insert 功能 +首先为 Session 添加成员变量 clause + +```go +// session/raw.go +type Session struct { + db *sql.DB + dialect dialect.Dialect + refTable *schema.Schema + clause clause.Clause + sql strings.Builder + sqlVars []interface{} +} + +func (s *Session) Clear() { + s.sql.Reset() + s.sqlVars = nil + s.clause = clause.Clause{} +} +``` + clause 已经支持生成简单的插入(INSERT) 和 查询(SELECT) 的 SQL 语句,那么紧接着我们就可以在 session 中实现对应的功能了。 INSERT 对应的 SQL 语句一般是这样的: @@ -216,7 +236,7 @@ INSERT INTO table_name(col1, col2, col3, ...) VALUES 在 ORM 框架中期望 Insert 的调用方式如下: ```go -s := geeorm.NewEngine().NewSession() +s := geeorm.NewEngine("sqlite3", "gee.db").NewSession() u1 := &User{Name: "Tom", Age: 18} u2 := &User{Name: "Sam", Age: 25} s.Insert(u1, u2, ...) @@ -282,7 +302,7 @@ func (s *Session) Insert(values ...interface{}) (int64, error) { 期望的调用方式是这样的:传入一个切片指针,查询的结果保存在切片中。 ```go -s := geeorm.NewEngine().NewSession() +s := geeorm.NewEngine("sqlite3", "gee.db").NewSession() var users []User s.Find(&users); ``` diff --git a/gee-orm/doc/geeorm-day4.md b/gee-orm/doc/geeorm-day4.md index 98cc55c..6e0d307 100644 --- a/gee-orm/doc/geeorm-day4.md +++ b/gee-orm/doc/geeorm-day4.md @@ -1,7 +1,7 @@ --- title: 动手写ORM框架 - GeeORM第四天 链式操作与更新删除 -date: 2020-03-03 23:00:00 -description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。通过链式(chain)操作,支持查询条件(where, order by, limit 等)的叠加;实现记录的更新(update)和删除(delete)功能。 +date: 2020-03-08 16:00:00 +description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。通过链式(chain)操作,支持查询条件(where, order by, limit 等)的叠加;实现记录的更新(update)、删除(delete)和统计(count)功能。 tags: - Go nav: 从零实现 @@ -16,10 +16,268 @@ keywords: - delete from image: post/geeorm/geeorm_sm.jpg github: https://github.com/geektutu/7days-golang -published: false --- 本文是[7天用Go从零实现ORM框架GeeORM](https://geektutu.com/post/geeorm.html)的第四篇。 - 通过链式(chain)操作,支持查询条件(where, order by, limit 等)的叠加。 -- 实现记录的更新(update)和删除(delete)功能。 \ No newline at end of file +- 实现记录的更新(update)、删除(delete)和统计(count)功能。**代码约100行** + +## 1 支持 Update、Delete 和 Count + +### 1.1 子句生成器 + +clause 负责构造 SQL 语句,如果需要增加对更新(update)、删除(delete)和统计(count)功能的支持,第一步自然是在 clause 中实现 update、delete 和 count 子句的生成器。 + +第一步:在原来的基础上,新增 UPDATE、DELETE、COUNT 三个 `Type` 类型的枚举值。 + +[day4-chain-operation/clause/clause.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day4-chain-operation/clause) + +```go +// Support types for Clause +const ( + INSERT Type = iota + VALUES + SELECT + LIMIT + WHERE + ORDERBY + UPDATE + DELETE + COUNT +) +``` + +第二步:实现对应字句的 generator,并注册到全局变量 `generators` 中 + +[day4-chain-operation/clause/generator.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day4-chain-operation/clause) + +```go +func init() { + generators = make(map[Type]generator) + generators[INSERT] = _insert + generators[VALUES] = _values + generators[SELECT] = _select + generators[LIMIT] = _limit + generators[WHERE] = _where + generators[ORDERBY] = _orderBy + generators[UPDATE] = _update + generators[DELETE] = _delete + generators[COUNT] = _count +} + +func _update(values ...interface{}) (string, []interface{}) { + tableName := values[0] + m := values[1].(map[string]interface{}) + var keys []string + var vars []interface{} + for k, v := range m { + keys = append(keys, k+" = ?") + vars = append(vars, v) + } + return fmt.Sprintf("UPDATE %s SET %s", tableName, strings.Join(keys, ", ")), vars +} + +func _delete(values ...interface{}) (string, []interface{}) { + return fmt.Sprintf("DELETE FROM %s", values[0]), []interface{}{} +} + +func _count(values ...interface{}) (string, []interface{}) { + return _select(values[0], []string{"count(*)"}) +} +``` + +- `_update` 设计入参是2个,第一个参数是表名(table),第二个参数是 map 类型,表示待更新的键值对。 +- `_delete` 只有一个入参,即表名。 +- `_count` 只有一个入参,即表名,并复用了 `_select` 生成器。 + + +### 1.2 Update 方法 + +子句的 generator 已经准备好了,接下来和 Insert、Find 等方法一样,在 `session/record.go` 中按照一定顺序拼接 SQL 语句并调用就可以了。 + +[day4-chain-operation/session/record.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day4-chain-operation/session) + +```go +// support map[string]interface{} +// also support kv list: "Name", "Tom", "Age", 18, .... +func (s *Session) Update(kv ...interface{}) (int64, error) { + m, ok := kv[0].(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + for i := 0; i < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + } + s.clause.Set(clause.UPDATE, s.RefTable().Name, m) + sql, vars := s.clause.Build(clause.UPDATE, clause.WHERE) + result, err := s.Raw(sql, vars...).Exec() + if err != nil { + return 0, err + } + return result.RowsAffected() +} +``` + +Update 方法比较特别的一点在于,Update 接受 2 种入参,平铺开来的键值对和 map 类型的键值对。因为 generator 接受的参数是 map 类型的键值对,因此 `Update` 方法会动态地判断传入参数的类型,如果是不是 map 类型,则会自动转换。 + + +### 1.3 Delete 方法 + +```go +// Delete records with where clause +func (s *Session) Delete() (int64, error) { + s.clause.Set(clause.DELETE, s.RefTable().Name) + sql, vars := s.clause.Build(clause.DELETE, clause.WHERE) + result, err := s.Raw(sql, vars...).Exec() + if err != nil { + return 0, err + } + return result.RowsAffected() +} +``` + +### 1.4 Count 方法 + +```go +// Count records with where clause +func (s *Session) Count() (int64, error) { + s.clause.Set(clause.COUNT, s.RefTable().Name) + sql, vars := s.clause.Build(clause.COUNT, clause.WHERE) + row := s.Raw(sql, vars...).QueryRow() + var tmp int64 + if err := row.Scan(&tmp); err != nil { + return 0, err + } + return tmp, nil +} +``` + +## 2 链式调用(chain) + +链式调用是一种简化代码的编程方式,能够使代码更简洁、易读。链式调用的原理也非常简单,某个对象调用某个方法后,将该对象的引用/指针返回,即可以继续调用该对象的其他方法。通常来说,当某个对象需要一次调用多个方法来设置其属性时,就非常适合改造为链式调用了。 + +SQL 语句的构造过程就非常符合这个条件。SQL 语句由多个子句构成,典型的例如 SELECT 语句,往往需要设置查询条件(WHERE)、限制返回行数(LIMIT)等。理想的调用方式应该是这样的: + +```go +s := geeorm.NewEngine("sqlite3", "gee.db").NewSession() +var users []User +s.Where("Age > 18").Limit(3).Find(&users) +``` + +从上面的示例中,可以看出,`WHERE`、`LIMIT`、`ORDER BY` 等查询条件语句非常适合链式调用。这几个子句的 generator 在之前就已经实现了,那我们接下来在 `session/record.go` 中添加对应的方法即可。 + +[day4-chain-operation/session/record.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day4-chain-operation/session) + +```go +// Limit adds limit condition to clause +func (s *Session) Limit(num int) *Session { + s.clause.Set(clause.LIMIT, num) + return s +} + +// Where adds limit condition to clause +func (s *Session) Where(desc string, args ...interface{}) *Session { + var vars []interface{} + s.clause.Set(clause.WHERE, append(append(vars, desc), args...)...) + return s +} + +// OrderBy adds order by condition to clause +func (s *Session) OrderBy(desc string) *Session { + s.clause.Set(clause.ORDERBY, desc) + return s +} +``` + +## 3 First 只返回一条记录 + +很多时候,我们期望 SQL 语句只返回一条记录,比如根据某个童鞋的学号查询他的信息,返回结果有且只有一条。结合链式调用,我们可以非常容易地实现 First 方法。 + +```go +func (s *Session) First(value interface{}) error { + dest := reflect.Indirect(reflect.ValueOf(value)) + destSlice := reflect.New(reflect.SliceOf(dest.Type())).Elem() + if err := s.Limit(1).Find(destSlice.Addr().Interface()); err != nil { + return err + } + if destSlice.Len() == 0 { + return errors.New("NOT FOUND") + } + dest.Set(destSlice.Index(0)) + return nil +} +``` + +First 方法可以这么使用: + +```go +u := &User{} +_ = s.OrderBy("Age DESC").First(u) +``` + +> 实现原理:根据传入的类型,利用反射构造切片,调用 `Limit(1)` 限制返回的行数,调用 `Find` 方法获取到查询结果。 + +## 4 测试 + +接下来呢,我们在 `record_test.go` 中添加几个测试用例,检测功能是否正常。 + +```go +package session + +import "testing" + +var ( + user1 = &User{"Tom", 18} + user2 = &User{"Sam", 25} + user3 = &User{"Jack", 25} +) + +func testRecordInit(t *testing.T) *Session { + t.Helper() + s := NewSession().Model(&User{}) + err1 := s.DropTable() + err2 := s.CreateTable() + _, err3 := s.Insert(user1, user2) + if err1 != nil || err2 != nil || err3 != nil { + t.Fatal("failed init test records") + } + return s +} + +func TestSession_Limit(t *testing.T) { + s := testRecordInit(t) + var users []User + err := s.Limit(1).Find(&users) + if err != nil || len(users) != 1 { + t.Fatal("failed to query with limit condition") + } +} + +func TestSession_Update(t *testing.T) { + s := testRecordInit(t) + affected, _ := s.Where("Name = ?", "Tom").Update("Age", 30) + u := &User{} + _ = s.OrderBy("Age DESC").First(u) + + if affected != 1 || u.Age != 30 { + t.Fatal("failed to update") + } +} + +func TestSession_DeleteAndCount(t *testing.T) { + s := testRecordInit(t) + affected, _ := s.Where("Name = ?", "Tom").Delete() + count, _ := s.Count() + + if affected != 1 || count != 1 { + t.Fatal("failed to delete or count") + } +} +``` + +## 附 推荐阅读 + +- [Go 语言简明教程](https://geektutu.com/post/quick-golang.html) +- [Go Test 单元测试简明教程](https://geektutu.com/post/quick-go-test.html) +- [SQLite 常用命令速查表](https://geektutu.com/post/cheat-sheet-sqlite.html) \ No newline at end of file diff --git a/gee-orm/doc/geeorm-day5.md b/gee-orm/doc/geeorm-day5.md index cbb86cd..e0bf9e2 100644 --- a/gee-orm/doc/geeorm-day5.md +++ b/gee-orm/doc/geeorm-day5.md @@ -1,6 +1,6 @@ --- title: 动手写ORM框架 - GeeORM第五天 实现钩子(Hooks) -date: 2020-03-03 23:00:00 +date: 2020-03-08 18:00:00 description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。通过反射(reflect)获取结构体绑定的钩子(hooks),并调用;支持增删查改(CRUD)前后调用钩子。 tags: - Go @@ -16,10 +16,140 @@ keywords: - BeforeUpdate image: post/geeorm/geeorm_sm.jpg github: https://github.com/geektutu/7days-golang -published: false --- 本文是[7天用Go从零实现ORM框架GeeORM](https://geektutu.com/post/geeorm.html)的第五篇。 - 通过反射(reflect)获取结构体绑定的钩子(hooks),并调用。 -- 支持增删查改(CRUD)前后调用钩子。 \ No newline at end of file +- 支持增删查改(CRUD)前后调用钩子。**代码约50行** + +## 1 Hook 机制 + +Hook,翻译为钩子,其主要思想是提前在可能增加功能的地方埋好(预设)一个钩子,当我们需要重新修改或者增加这个地方的逻辑的时候,把扩展的类或者方法挂载到这个点即可。钩子的应用非常广泛,例如 Github 支持的 travis 持续集成服务,当有 `git push` 事件发生时,会触发 travis 拉取新的代码进行构建。IDE 中钩子也非常常见,比如,当按下 `Ctrl + s` 后,自动格式化代码。再比如前端常用的 `hot reload` 机制,前端代码发生变更时,自动编译打包,通知浏览器自动刷新页面,实现所写即所得。 + +钩子机制设计的好坏,取决于扩展点选择的是否合适。例如对于持续集成来说,代码如果不发生变更,反复构建是没有意义的,因此钩子应设计在代码可能发生变更的地方,比如 MR、PR 合并前后。 + +那对于 ORM 框架来说,合适的扩展点在哪里呢?很显然,记录的增删查改前后都是非常合适的。 + +比如,我们设计一个 `Account` 类,`Account` 包含有一个隐私字段 `Password`,那么每次查询后都需要做脱敏处理,才能继续使用。如果提供了 `AfterQuery` 的钩子,查询后,自动地将 `Password` 字段的值脱敏,是不是能省去很多冗余的代码呢? + +## 2 实现钩子 + +GeeORM 的钩子与结构体绑定,即每个结构体需要实现各自的钩子。hook 相关的代码实现在 `session/hooks.go` 中。 + +[day5-hooks/session/hooks.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day5-hooks/session) + +```go +package session + +import ( + "geeorm/log" + "reflect" +) + +// Hooks constants +const ( + BeforeQuery = "BeforeQuery" + AfterQuery = "AfterQuery" + BeforeUpdate = "BeforeUpdate" + AfterUpdate = "AfterUpdate" + BeforeDelete = "BeforeDelete" + AfterDelete = "AfterDelete" + BeforeInsert = "BeforeInsert" + AfterInsert = "AfterInsert" +) + +// CallMethod calls the registered hooks +func (s *Session) CallMethod(method string, value interface{}) { + fm := reflect.ValueOf(s.RefTable().Model).MethodByName(method) + if value != nil { + fm = reflect.ValueOf(value).MethodByName(method) + } + param := []reflect.Value{reflect.ValueOf(s)} + if fm.IsValid() { + if v := fm.Call(param); len(v) > 0 { + if err, ok := v[0].Interface().(error); ok { + log.Error(err) + } + } + } + return +} +``` + +- 钩子机制同样是通过反射来实现的,`s.RefTable().Model` 或 `value` 即当前会话正在操作的对象,使用 `MethodByName` 方法反射得到该对象的方法。 +- 将 `s *Session` 作为入参调用。每一个钩子的入参类型均是 `*Session`。 + +接下来,将 `CallMethod()` 方法在 Find、Insert、Update、Delete 方法内部调用即可。例如,`Find` 方法修改为: + +```go +// Find gets all eligible records +func (s *Session) Find(values interface{}) error { + s.CallMethod(BeforeQuery, nil) + // ... + for rows.Next() { + dest := reflect.New(destType).Elem() + // ... + s.CallMethod(AfterQuery, dest.Addr().Interface()) + // ... + } + return rows.Close() +} +``` + +- `AfterQuery` 钩子可以操作每一行记录。 + +## 3 测试 + +新建 `session/hooks.go` 文件添加对应的测试用例。 + +```go +package session + +import ( + "geeorm/log" + "testing" +) + +type Account struct { + ID int `geeorm:"PRIMARY KEY"` + Password string +} + +func (account *Account) BeforeInsert(s *Session) error { + log.Info("before inert", account) + account.ID += 1000 + return nil +} + +func (account *Account) AfterQuery(s *Session) error { + log.Info("after query", account) + account.Password = "******" + return nil +} + +func TestSession_CallMethod(t *testing.T) { + s := NewSession().Model(&Account{}) + _ = s.DropTable() + _ = s.CreateTable() + _, _ = s.Insert(&Account{1, "123456"}, &Account{2, "qwerty"}) + + u := &Account{} + + err := s.First(u) + if err != nil || u.ID != 1001 || u.Password != "******" { + t.Fatal("Failed to call hooks after query, got", u) + } +} +``` + +在这个测试用例中,测试了 `BeforeInsert` 和 `AfterQuery` 2 个钩子。 + +- `BeforeInsert` 将 account.ID 的值增加 1000 +- `AfterQuery` 将密码脱敏,显示为 6 个 `*`。 + +## 附 推荐阅读 + +- [Go 语言简明教程](https://geektutu.com/post/quick-golang.html) +- [Go Test 单元测试简明教程](https://geektutu.com/post/quick-go-test.html) +- [SQLite 常用命令速查表](https://geektutu.com/post/cheat-sheet-sqlite.html) \ No newline at end of file diff --git a/gee-orm/doc/geeorm-day6.md b/gee-orm/doc/geeorm-day6.md index e6f6f03..37955d3 100644 --- a/gee-orm/doc/geeorm-day6.md +++ b/gee-orm/doc/geeorm-day6.md @@ -1,7 +1,7 @@ --- title: 动手写ORM框架 - GeeORM第六天 支持事务(Transaction) -date: 2020-03-03 23:00:00 -description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。介绍 SQLite 数据库中的事务(transaction);封装事务,用户自定义回调函数实现原子操作。 +date: 2020-03-08 21:00:00 +description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。介绍数据库中的事务(transaction);封装事务,用户自定义回调函数实现原子操作。 tags: - Go nav: 从零实现 @@ -15,10 +15,257 @@ keywords: - transaction image: post/geeorm/geeorm_sm.jpg github: https://github.com/geektutu/7days-golang -published: false --- 本文是[7天用Go从零实现ORM框架GeeORM](https://geektutu.com/post/geeorm.html)的第六篇。 -- 介绍 SQLite 数据库中的事务(transaction)。 -- 封装事务,用户自定义回调函数实现原子操作。 \ No newline at end of file +- 介绍数据库中的事务(transaction)。 +- 封装事务,用户自定义回调函数实现原子操作。**代码约100行** + +## 1 事务的 ACID 属性 + +> 数据库事务(transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成。 + +举一个简单的例子,转账。A 转账给 B 一万元,那么数据库至少需要执行 2 个操作: + +- 1)A 的账户减掉一万元。 +- 2)B 的账户增加一万元。 + +这两个操作要么全部执行,代表转账成功。任意一个操作失败了,之前的操作都必须回退,代表转账失败。一个操作完成,另一个操作失败,这种结果是不能够接受的。这种场景就非常适合利用数据库事务的特性来解决。 + +如果一个数据库支持事务,那么必须具备 ACID 四个属性。 + +- 1)原子性(Atomicity):事务中的全部操作在数据库中是不可分割的,要么全部完成,要么全部不执行。 +- 2)一致性(Consistency): 几个并行执行的事务,其执行结果必须与按某一顺序 串行执行的结果相一致。 +- 3)隔离性(Isolation):事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务必须是透明的。 +- 4)持久性(Durability):对于任意已提交事务,系统必须保证该事务对数据库的改变不被丢失,即使数据库出现故障。 + +## 2 SQLite 和 Go 标准库中的事务 + +SQLite 中创建一个事务的原生 SQL 长什么样子呢? + +```sql +sqlite> BEGIN; +sqlite> DELETE FROM User WHERE Age > 25; +sqlite> INSERT INTO User VALUES ("Tom", 25), ("Jack", 18); +sqlite> COMMIT; +``` + +`BEGIN` 开启事务,`COMMIT` 提交事务,`ROLLBACK` 回滚事务。任何一个事务,均以 `BEGIN` 开始,`COMMIT` 或 `ROLLBACK` 结束。 + +Go 语言标准库 database/sql 提供了支持事务的接口。用一个简单的例子,看一看 Go 语言标准是如何支持事务的。 + +```go +package main + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "log" +) + +func main() { + db, _ := sql.Open("sqlite3", "gee.db") + defer func() { _ = db.Close() }() + _, _ = db.Exec("CREATE TABLE IF NOT EXISTS User(`Name` text);") + + tx, _ := db.Begin() + _, err1 := tx.Exec("INSERT INTO User(`Name`) VALUES (?)", "Tom") + _, err2 := tx.Exec("INSERT INTO User(`Name`) VALUES (?)", "Jack") + if err1 != nil || err2 != nil { + _ = tx.Rollback() + log.Println("Rollback", err1, err2) + } else { + _ = tx.Commit() + log.Println("Commit") + } +} +``` + +Go 语言中实现事务和 SQL 原生语句其实是非常接近的。调用 `db.Begin()` 得到 `*sql.Tx` 对象,使用 `tx.Exec()` 执行一系列操作,如果发生错误,通过 `tx.Rollback()` 回滚,如果没有发生错误,则通过 `tx.Commit()` 提交。 + +## 3 GeeORM 支持事务 + +GeeORM 之前的操作均是执行完即自动提交的,每个操作是相互独立的。之前直接使用 `sql.DB` 对象执行 SQL 语句,如果要支持事务,需要更改为 `sql.Tx` 执行。在 Session 结构体中新增成员变量 `tx *sql.Tx`,当 `tx` 不为空时,则使用 `tx` 执行 SQL 语句,否则使用 `db` 执行 SQL 语句。这样既兼容了原有的执行方式,又提供了对事务的支持。 + +[day6-transaction/session/raw.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day6-transaction/session) + +```go +type Session struct { + db *sql.DB + dialect dialect.Dialect + tx *sql.Tx + refTable *schema.Schema + clause clause.Clause + sql strings.Builder + sqlVars []interface{} +} + +// CommonDB is a minimal function set of db +type CommonDB interface { + Query(query string, args ...interface{}) (*sql.Rows, error) + QueryRow(query string, args ...interface{}) *sql.Row + Exec(query string, args ...interface{}) (sql.Result, error) +} + +var _ CommonDB = (*sql.DB)(nil) +var _ CommonDB = (*sql.Tx)(nil) + +// DB returns tx if a tx begins. otherwise return *sql.DB +func (s *Session) DB() CommonDB { + if s.tx != nil { + return s.tx + } + return s.db +} +``` + +新建文件 `session/transaction.go` 封装事务的 Begin、Commit 和 Rollback 三个接口。 + +[day6-transaction/session/transaction.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day6-transaction/session) + +```go +package session + +import "geeorm/log" + +func (s *Session) Begin() (err error) { + log.Info("transaction begin") + if s.tx, err = s.db.Begin(); err != nil { + log.Error(err) + return + } + return +} + +func (s *Session) Commit() (err error) { + log.Info("transaction commit") + if err = s.tx.Commit(); err != nil { + log.Error(err) + } + return +} + +func (s *Session) Rollback() (err error) { + log.Info("transaction rollback") + if err = s.tx.Rollback(); err != nil { + log.Error(err) + } + return +} +``` + +- 调用 `s.db.Begin()` 得到 `*sql.Tx` 对象,赋值给 s.tx。 +- 封装的另一个目的是统一打印日志,方便定位问题。 + + +最后一步,在 `geeorm.go` 中为用户提供傻瓜式/一键式使用的接口。 + +[day6-transaction/geeorm.go](https://github.com/geektutu/7days-golang/tree/master/gee-orm/day6-transaction) + +```go +type TxFunc func(*session.Session) (interface{}, error) + +func (engine *Engine) Transaction(f TxFunc) (result interface{}, err error) { + s := engine.NewSession() + if err := s.Begin(); err != nil { + return nil, err + } + defer func() { + if p := recover(); p != nil { + _ = s.Rollback() + panic(p) // re-throw panic after Rollback + } else if err != nil { + _ = s.Rollback() // err is non-nil; don't change it + } else { + err = s.Commit() // err is nil; if Commit returns error update err + } + }() + + return f(s) +} +``` + +> Transaction 的实现参考了 [stackoverflow](https://stackoverflow.com/questions/16184238/database-sql-tx-detecting-commit-or-rollback) + +用户只需要将所有的操作放到一个回调函数中,作为入参传递给 `engine.Transaction()`,发生任何错误,自动回滚,如果没有错误发生,则提交。 + +## 4 测试 + +在 `geeorm_test.go` 中添加测试用例看看 Transaction 如何工作的吧。 + +```go +func OpenDB(t *testing.T) *Engine { + t.Helper() + engine, err := NewEngine("sqlite3", "gee.db") + if err != nil { + t.Fatal("failed to connect", err) + } + return engine +} + +type User struct { + Name string `geeorm:"PRIMARY KEY"` + Age int +} + +func TestEngine_Transaction(t *testing.T) { + t.Run("rollback", func(t *testing.T) { + transactionRollback(t) + }) + t.Run("commit", func(t *testing.T) { + transactionCommit(t) + }) +} +``` + +首先是 rollback 的用例: + +```go +func transactionRollback(t *testing.T) { + engine := OpenDB(t) + defer engine.Close() + s := engine.NewSession() + _ = s.Model(&User{}).DropTable() + _, err := engine.Transaction(func(s *session.Session) (result interface{}, err error) { + _ = s.Model(&User{}).CreateTable() + _, err = s.Insert(&User{"Tom", 18}) + return nil, errors.New("Error") + }) + if err == nil || s.HasTable() { + t.Fatal("failed to rollback") + } +} +``` + +- 在这个用例中,如何执行成功,则会创建一张表 `User`,并插入一条记录。 +- 故意返回了一个自定义 error,最终事务回滚,表创建失败。 + +接下来是 commit 的用例: + +```go +func transactionCommit(t *testing.T) { + engine := OpenDB(t) + defer engine.Close() + s := engine.NewSession() + _ = s.Model(&User{}).DropTable() + _, err := engine.Transaction(func(s *session.Session) (result interface{}, err error) { + _ = s.Model(&User{}).CreateTable() + _, err = s.Insert(&User{"Tom", 18}) + return + }) + u := &User{} + _ = s.First(u) + if err != nil || u.Name != "Tom" { + t.Fatal("failed to commit") + } +} +``` + +- 创建表和插入记录均成功执行,最终通过 `s.First()` 方法查询到插入的记录。 + +## 附 推荐阅读 + +- [Go 语言简明教程](https://geektutu.com/post/quick-golang.html) +- [Go Test 单元测试简明教程](https://geektutu.com/post/quick-go-test.html) +- [SQLite 常用命令速查表](https://geektutu.com/post/cheat-sheet-sqlite.html) \ No newline at end of file diff --git a/gee-orm/doc/geeorm-day7.md b/gee-orm/doc/geeorm-day7.md index 6e2bf40..44079fd 100644 --- a/gee-orm/doc/geeorm-day7.md +++ b/gee-orm/doc/geeorm-day7.md @@ -1,6 +1,6 @@ --- title: 动手写ORM框架 - GeeORM第七天 数据库迁移(Migrate) -date: 2020-03-03 23:00:00 +date: 2020-03-08 23:00:00 description: 7天用 Go语言/golang 从零实现 ORM 框架 GeeORM 教程(7 days implement golang object relational mapping framework from scratch tutorial),动手写 ORM 框架,参照 gorm, xorm 的实现。结构体(struct)变更时,数据库表的字段(field)自动迁移(migrate);仅支持字段新增与删除,不支持字段类型变更。 tags: - Go @@ -15,10 +15,154 @@ keywords: - migrate image: post/geeorm/geeorm_sm.jpg github: https://github.com/geektutu/7days-golang -published: false --- 本文是[7天用Go从零实现ORM框架GeeORM](https://geektutu.com/post/geeorm.html)的第七篇。 - 结构体(struct)变更时,数据库表的字段(field)自动迁移(migrate)。 -- 仅支持字段新增与删除,不支持字段类型变更。 \ No newline at end of file +- 仅支持字段新增与删除,不支持字段类型变更。**代码约70行** + +## 1 使用 SQL 语句 Migrate + +数据库 Migrate 一直是数据库运维人员最为头痛的问题,如果仅仅是一张表增删字段还比较容易,那如果涉及到外键等复杂的关联关系,数据库的迁移就会变得非常困难。 + +GeeORM 的 Migrate 操作仅针对最为简单的场景,即支持字段的新增与删除,不支持字段类型变更。 + +在实现 Migrate 之前,我们先看看如何使用原生的 SQL 语句增删字段。 + +### 1.1 新增字段 + +```sql +ALTER TABLE table_name ADD COLUMN col_name, col_type; +``` + +大部分数据支持使用 `ALTER` 关键字新增字段,或者重命名字段。 + +### 1.2 删除字段 + +> 参考 [sqlite delete or add column - stackoverflow](https://stackoverflow.com/questions/8442147/how-to-delete-or-add-column-in-sqlite) + +对于 SQLite 来说,删除字段并不像新增字段那么容易,一个比较可行的方法需要执行下列几个步骤: + +```sql +CREATE TABLE new_table AS SELECT col1, col2, ... from old_table +DROP TABLE old_table +ALTER TABLE new_table RENAME TO old_table; +``` + +- 第一步:从 `old_table` 中挑选需要保留的字段到 `new_table` 中。 +- 第二步:删除 `old_table`。 +- 第三步:重命名 `new_table` 为 `old_table`。 + +## 2 GeeORM 实现 Migrate + +按照原生的 SQL 命令,利用之前实现的事务,在 `geeorm.go` 中实现 Migrate 方法。 + +```go +// difference returns a - b +func difference(a []string, b []string) (diff []string) { + mapB := make(map[string]bool) + for _, v := range b { + mapB[v] = true + } + for _, v := range a { + if _, ok := mapB[v]; !ok { + diff = append(diff, v) + } + } + return +} + +// Migrate table +func (engine *Engine) Migrate(value interface{}) error { + _, err := engine.Transaction(func(s *session.Session) (result interface{}, err error) { + if !s.Model(value).HasTable() { + log.Infof("table %s doesn't exist", s.RefTable().Name) + return nil, s.CreateTable() + } + table := s.RefTable() + rows, _ := s.Raw(fmt.Sprintf("SELECT * FROM %s LIMIT 1", table.Name)).QueryRows() + columns, _ := rows.Columns() + addCols := difference(table.FieldNames, columns) + delCols := difference(columns, table.FieldNames) + log.Infof("added cols %v, deleted cols %v", addCols, delCols) + + for _, col := range addCols { + f := table.GetField(col) + sqlStr := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s;", table.Name, f.Name, f.Tag) + if _, err = s.Raw(sqlStr).Exec(); err != nil { + return + } + } + + if len(delCols) == 0 { + return + } + tmp := "tmp_" + table.Name + fieldStr := strings.Join(table.FieldNames, ", ") + s.Raw(fmt.Sprintf("CREATE TABLE %s AS SELECT %s from %s;", tmp, fieldStr, table.Name)) + s.Raw(fmt.Sprintf("DROP TABLE %s;", table.Name)) + s.Raw(fmt.Sprintf("ALTER TABLE %s RENAME TO %s;", tmp, table.Name)) + _, err = s.Exec() + return + }) + return err +} +``` + +- `difference` 用来计算前后两个字段切片的差集。新表 - 旧表 = 新增字段,旧表 - 新表 = 删除字段。 +- 使用 `ALTER` 语句新增字段。 +- 使用创建新表并重命名的方式删除字段。 + +## 3 测试 + +在 `geeorm_test.go` 中添加 Migrate 的测试用例: + +```go +type User struct { + Name string `geeorm:"PRIMARY KEY"` + Age int +} + +func TestEngine_Migrate(t *testing.T) { + engine := OpenDB(t) + defer engine.Close() + s := engine.NewSession() + _, _ = s.Raw("DROP TABLE IF EXISTS User;").Exec() + _, _ = s.Raw("CREATE TABLE User(Name text PRIMARY KEY, XXX integer);").Exec() + _, _ = s.Raw("INSERT INTO User(`Name`) values (?), (?)", "Tom", "Sam").Exec() + engine.Migrate(&User{}) + + rows, _ := s.Raw("SELECT * FROM User").QueryRows() + columns, _ := rows.Columns() + if !reflect.DeepEqual(columns, []string{"Name", "Age"}) { + t.Fatal("Failed to migrate table User, got columns", columns) + } +} +``` + +- 首先假设原有的 `User` 包含两个字段 `Name` 和 `XXX`,在一次业务变更之后,`User` 结构体的字段变更为 `Name` 和 `Age`。 +- 即需要删除原有字段 `XXX`,并新增字段 `Age`。 +- 调用 `Migrate(&User{})` 之后,新表的结构为 `Name`,`Age` + +## 4 总结 + +GeeORM 的整体实现比较粗糙,比如数据库的迁移仅仅考虑了最简单的场景。实现的特性也比较少,比如结构体嵌套的场景,外键的场景,复合主键的场景都没有覆盖。ORM 框架的代码规模一般都比较大,如果想尽可能地逼近数据库,就需要大量的代码来实现相关的特性;二是数据库之间的差异也是比较大的,实现的功能越多,数据库之间的差异就会越突出,有时候为了达到较好的性能,就不得不为每个数据做特殊处理;还有些 ORM 框架同时支持关系型数据库和非关系型数据库,这就要求框架本身有更高层次的抽象,不能局限在 SQL 这一层。 + +GeeORM 仅 800 左右的代码是不可能做到这一点的。不过,GeeORM 的目的并不是实现一个可以在生产使用的 ORM 框架,而是希望尽可能多地介绍 ORM 框架大致的实现原理,例如 + +- 在框架中如何屏蔽不同数据库之间的差异; +- 数据库中表结构和编程语言中的对象是如何映射的; +- 如何优雅地模拟查询条件,链式调用是个不错的选择; +- 为什么 ORM 框架通常会提供 hooks 扩展的能力; +- 事务的原理和 ORM 框架如何集成对事务的支持; +- 一些难点问题,例如数据库迁移。 +- ... + +基于这几点,我觉得 GeeORM 的目的达到了。 + +## 附 推荐阅读 + +- [Go Test 单元测试简明教程](https://geektutu.com/post/quick-go-test.html) +- [SQLite 常用命令速查表](https://geektutu.com/post/cheat-sheet-sqlite.html) +- [sqlite delete or add column - stackoverflow](https://stackoverflow.com/questions/8442147/how-to-delete-or-add-column-in-sqlite) diff --git a/gee-orm/doc/geeorm.md b/gee-orm/doc/geeorm.md index c25af6c..f9c14da 100644 --- a/gee-orm/doc/geeorm.md +++ b/gee-orm/doc/geeorm.md @@ -122,13 +122,13 @@ gorm 正在彻底重构 v1 版本,短期内看不到发布 v2 的可能。相 ## 3 目录 -- 第一天:database/sql 基础 | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day1-database-sql) -- 第二天:对象表结构映射 | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day2-reflect-schema) -- 第三天:插入/查询记录 | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day3-save-query) -- 第四天:链式操作与更新删除 | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day4-chain-operation) -- 第五天:实现钩子(Hooks) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day5-hooks) -- 第六天:支持事务(Transaction) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day6-transaction) -- 第七天:数据库迁移(Migrate) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day7-migrate) +- 第一天:[database/sql 基础](https://geektutu.com/post/geeorm-day1.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day1-database-sql) +- 第二天:[对象表结构映射](https://geektutu.com/post/geeorm-day2.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day2-reflect-schema) +- 第三天:[记录新增和查询](https://geektutu.com/post/geeorm-day3.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day3-save-query) +- 第四天:[链式操作与更新删除](https://geektutu.com/post/geeorm-day4.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day4-chain-operation) +- 第五天:[实现钩子(Hooks)](https://geektutu.com/post/geeorm-day5.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day5-hooks) +- 第六天:[支持事务(Transaction)](https://geektutu.com/post/geeorm-day6.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day6-transaction) +- 第七天:[数据库迁移(Migrate)](https://geektutu.com/post/geeorm-day7.html) | [Code](https://github.com/geektutu/7days-golang/blob/master/gee-orm/day7-migrate) ## 附 推荐阅读