package modelgenerate import ( "fmt" "go/ast" "go/token" "strings" "github.com/jinzhu/inflection" pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema" "git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils" ) // SampleValue returns a deterministic value of the appropriate type for the given column. func SampleValue(c pkgschema.Column, offset int) ast.Expr { switch c.Type { case "integer", "int": if strings.HasPrefix(c.Name, "is_") || strings.HasPrefix(c.Name, "has_") { // Boolean case if offset%2 == 0 { return ast.NewIdent("false") } return ast.NewIdent("true") } else if strings.HasSuffix(c.Name, "_at") { // Timestamp case return &ast.CallExpr{ Fun: ast.NewIdent("TimestampFromUnix"), Args: []ast.Expr{ &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10000+offset)}, }, } } else { // Regular integer case return &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10+offset)} } case "text": if offset == 0 { return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", "asdf")} } return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", fmt.Sprintf("asdf%d", offset))} case "real": return &ast.BasicLit{Kind: token.FLOAT, Value: fmt.Sprintf("%.2f", 1.23+float64(offset))} case "blob": return &ast.CompositeLit{ Type: &ast.ArrayType{ Elt: ast.NewIdent("byte"), }, Elts: func() []ast.Expr { ret := []ast.Expr{ &ast.BasicLit{Kind: token.INT, Value: "72"}, // 'H' &ast.BasicLit{Kind: token.INT, Value: "105"}, // 'i' &ast.BasicLit{Kind: token.INT, Value: "33"}, // '!' } for range offset { ret = append(ret, &ast.BasicLit{Kind: token.INT, Value: "33"}, // '!' ) } return ret }(), } default: panic("Unrecognized sqlite column type: " + c.Type) } } // UpdateTestFields returns the columns for which the test generator should assign its own // sample values, in the MakeXyz() factory and in the create/update test: the same columns // that GenerateSaveItemFunc's "update" branch writes to, minus foreign keys (which can't be // given plausible values here) and the auto-managed "created_at"/"updated_at" columns. func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) { hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() for _, c := range tbl.Columns { if c.Name == "rowid" || c.IsPrimaryKey || c.IsForeignKey { continue } if c.Name == "created_at" && hasCreatedAt { continue } if c.Name == "updated_at" && hasUpdatedAt { continue } ret = append(ret, c) } return } // GenerateModelTestAST produces an AST for a starter test file for a given model. func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodName string) *ast.File { packageName := "db" testpackageName := packageName + "_test" makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName) hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() updateTestFields := UpdateTestFields(tbl) // func MakeItem() Item { return Item{} } makeItemFunc := &ast.FuncDecl{ Name: makeHelperName, Type: &ast.FuncType{ Params: &ast.FieldList{}, Results: &ast.FieldList{ List: []*ast.Field{ {Type: ast.NewIdent(tbl.GoTypeName)}, }, }, }, Body: &ast.BlockStmt{ List: []ast.Stmt{ &ast.ReturnStmt{ Results: []ast.Expr{ &ast.CompositeLit{ Type: ast.NewIdent(tbl.GoTypeName), Elts: func() (ret []ast.Expr) { for _, c := range updateTestFields { ret = append(ret, &ast.KeyValueExpr{ Key: ast.NewIdent(c.GoFieldName()), Value: SampleValue(c, 0), }) } return }(), }, }, }, }, }, } testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName)) testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2") testDB := ast.NewIdent("TestDB") // getItemByPKCall builds a call to this table's primary-key getter (e.g. `TestDB.GetItemByID(item.ID)`, // or `TestDB.GetItemByColAAndColB(item.ColA, item.ColB)` for "without rowid" tables with a // compound primary key), matching whatever GenerateGetItemByIDFunc/GenerateGetItemBy generated. getItemByPKCall := func(obj *ast.Ident) *ast.CallExpr { if !tbl.IsWithoutRowid { // Normal rowid table: use GetXyzByID return &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")}, Args: []ast.Expr{&ast.SelectorExpr{X: obj, Sel: ast.NewIdent("ID")}}, } } else { // "Without rowid" table: use the primary key "GetItemByBlahBlah" query func funcNameSuffix := []string{} args := []ast.Expr{} for _, c := range tbl.PrimaryKeyColumns() { funcNameSuffix = append(funcNameSuffix, c.GoFieldName()) args = append(args, &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(c.GoFieldName())}) } return &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "By" + strings.Join(funcNameSuffix, "And"))}, Args: args, } } } makeDeepEqual := func(obj1 *ast.Ident, obj2 *ast.Ident) *ast.IfStmt { return &ast.IfStmt{ Init: &ast.AssignStmt{ Lhs: []ast.Expr{ &ast.Ident{Name: "diff"}, }, Tok: token.DEFINE, Rhs: []ast.Expr{ &ast.CallExpr{ Fun: &ast.SelectorExpr{ X: &ast.Ident{Name: "deep"}, Sel: &ast.Ident{Name: "Equal"}, }, Args: []ast.Expr{obj1, obj2}, }, }, }, Cond: &ast.BinaryExpr{ X: &ast.Ident{Name: "diff"}, Op: token.NEQ, Y: &ast.Ident{Name: "nil"}, }, Body: &ast.BlockStmt{ List: []ast.Stmt{ &ast.ExprStmt{ X: &ast.CallExpr{ Fun: &ast.SelectorExpr{ X: &ast.Ident{Name: "t"}, Sel: &ast.Ident{Name: "Error"}, }, Args: []ast.Expr{ &ast.Ident{Name: "diff"}, }, }, }, }, }, } } testFuncType := &ast.FuncType{ Params: &ast.FieldList{ List: []*ast.Field{{ Names: []*ast.Ident{ast.NewIdent("t")}, Type: &ast.StarExpr{X: &ast.SelectorExpr{X: ast.NewIdent("testing"), Sel: ast.NewIdent("T")}}, }}, }, } // Generate FK Check test func first, because it also detects whether there are foreign keys shouldIncludeTestFkCheck := false testFkChecking := &ast.FuncDecl{ Name: ast.NewIdent("Test" + tbl.GoTypeName + "FkChecking"), Type: testFuncType, Body: &ast.BlockStmt{ List: func() (stmts []ast.Stmt) { isFirst := true for _, col := range tbl.Columns { if !col.IsForeignKey { continue } shouldIncludeTestFkCheck = true // post := MakePost() if !isFirst { stmts = append(stmts, BlankLine()) } stmts = append(stmts, []ast.Stmt{ // Comment header Comment(fmt.Sprintf("Invalid %s", col.GoFieldName())), // `Invalid &ast.AssignStmt{ Lhs: []ast.Expr{ast.NewIdent(tbl.VarName)}, Tok: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[isFirst], Rhs: []ast.Expr{ &ast.CallExpr{ Fun: ast.NewIdent("Make" + tbl.GoTypeName), }, }, }, // `post.QuotedPostID = 94354538969386985` &ast.AssignStmt{ Lhs: []ast.Expr{ &ast.SelectorExpr{ X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(col.GoFieldName()), }, }, Tok: token.ASSIGN, Rhs: []ast.Expr{ &ast.BasicLit{ Kind: token.INT, Value: "94354538969386985", }, }, }, // `err := db.SavePost(&post)` &ast.AssignStmt{ Lhs: []ast.Expr{ast.NewIdent("err")}, Tok: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[isFirst], Rhs: []ast.Expr{ &ast.CallExpr{ Fun: &ast.SelectorExpr{ X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName), }, Args: []ast.Expr{ &ast.UnaryExpr{ Op: token.AND, X: ast.NewIdent(tbl.VarName), }, }, }, }, }, // `assertForeignKeyError(t, err, "QuotedPostID", post.QuotedPostID)` &ast.ExprStmt{ X: &ast.CallExpr{ Fun: ast.NewIdent("AssertForeignKeyError"), Args: []ast.Expr{ ast.NewIdent("t"), ast.NewIdent("err"), &ast.BasicLit{ Kind: token.STRING, Value: fmt.Sprintf("%q", col.GoFieldName()), }, &ast.SelectorExpr{ X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(col.GoFieldName()), }, }, }, }, }...) isFirst = false } return stmts }(), }, } testCreateUpdateDelete := &ast.FuncDecl{ Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName), Type: testFuncType, Body: &ast.BlockStmt{ List: func() []ast.Stmt { assertNotZero := func(obj *ast.Ident, field string) *ast.ExprStmt { return &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("NotZero")}, Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(field)}}, }} } stmts := []ast.Stmt{ Comment("Create"), // item := MakeItem() &ast.AssignStmt{ Lhs: []ast.Expr{testObj}, Tok: token.DEFINE, Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}}, }, // item.Description = // ...one assignment per updatable field } for _, c := range updateTestFields { stmts = append(stmts, &ast.AssignStmt{ Lhs: []ast.Expr{ &ast.SelectorExpr{ X: testObj, Sel: ast.NewIdent(c.GoFieldName()), }, }, Tok: token.ASSIGN, Rhs: []ast.Expr{SampleValue(c, 1)}, }) } stmts = append(stmts, // TestDB.SaveItem(&item), possibly with error check &ast.ExprStmt{X: func() *ast.CallExpr { mainExpr := &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)}, Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}}, } if shouldIncludeTestFkCheck { // Also a check for whether the Save function returns an error return &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NoError")}, Args: []ast.Expr{ast.NewIdent("t"), mainExpr}, } } return mainExpr }()}, ) // require.NotZero(t, item.ID) if !tbl.IsWithoutRowid { // non-rowid tables don't get an ID stmts = append(stmts, &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NotZero")}, Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}}, }}) } // After create: assert timestamps are set if hasCreatedAt { stmts = append(stmts, assertNotZero(testObj, "CreatedAt")) } if hasUpdatedAt { stmts = append(stmts, assertNotZero(testObj, "UpdatedAt")) } stmts = append(stmts, BlankLine(), Comment("Load"), // item2 := must.Get(TestDB.GetItemByID(item.ID)) &ast.AssignStmt{ Lhs: []ast.Expr{testObj2}, Tok: token.DEFINE, Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))}, }, // if deep.Equal(...) {...} makeDeepEqual(testObj, testObj2), ) stmts = append(stmts, BlankLine(), Comment("Update"), ) // item.Description = // ...one assignment per updatable field for _, c := range updateTestFields { stmts = append(stmts, &ast.AssignStmt{ Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}}, Tok: token.ASSIGN, Rhs: []ast.Expr{SampleValue(c, 2)}, }) } stmts = append(stmts, // TestDB.SaveItem(&item) &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)}, Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}}, }}, // item2 = must.Get(TestDB.GetItemByID(item.ID)) &ast.AssignStmt{ Lhs: []ast.Expr{testObj2}, Tok: token.ASSIGN, Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))}, }, // if deep.Equal(...) {...} makeDeepEqual(testObj, testObj2), ) indexGets, hasIndexedGets := []ast.Stmt{ BlankLine(), Comment("Indexed lookups"), }, false for _, index := range schema.Indexes { if index.TableName != tbl.TableName { // Skip indexes on other tables continue } if len(index.Columns) != 1 || index.Columns[0] == "" { // Skip multi-column and expression indexes continue } col := tbl.GetColumnByName(index.Columns[0]) if index.IsUnique { indexGets = append(indexGets, []ast.Stmt{ // assert.Equal(t, item2, TestDB.GetItemByXYZ(...)) &ast.ExprStmt{X: &ast.CallExpr{ // TODO: what if just delete the "ExprStmt" wrapper? Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Equal")}, Args: []ast.Expr{ ast.NewIdent("t"), testObj2, mustCall(&ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent( "Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName(), )}, Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}}, }), }, }}, }...) } else { indexGets = append(indexGets, []ast.Stmt{ // assert.Contains(t, TestDB.GetItemsByXYZ(...), item2) &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Contains")}, Args: []ast.Expr{ ast.NewIdent("t"), &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent( "Get" + inflection.Plural(pkgschema.TypenameFromTablename(tbl.TableName)) + "By" + col.GoFieldName(), )}, Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}}, }, testObj2, }, }}, }...) } hasIndexedGets = true } if hasIndexedGets { stmts = append(stmts, indexGets...) } stmts = append(stmts, BlankLine(), Comment("Delete"), // TestDB.DeleteItem(item) &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Delete" + tbl.GoTypeName)}, Args: []ast.Expr{testObj}, }}, // _, err := TestDB.GetItemByID(item.ID) &ast.AssignStmt{ Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")}, Tok: token.DEFINE, Rhs: []ast.Expr{getItemByPKCall(testObj)}, }, // assert.ErrorIs(t, err, db.ErrNotInDB) &ast.ExprStmt{X: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("ErrorIs")}, Args: []ast.Expr{ ast.NewIdent("t"), ast.NewIdent("err"), ast.NewIdent("ErrNotInDB"), }, }}, ) return stmts }(), }, } testGetAll := &ast.FuncDecl{ Name: ast.NewIdent("TestGetAll" + inflection.Plural(tbl.GoTypeName)), Type: testFuncType, Body: &ast.BlockStmt{ List: []ast.Stmt{ &ast.AssignStmt{ Lhs: []ast.Expr{ast.NewIdent("_")}, Tok: token.ASSIGN, Rhs: []ast.Expr{&ast.CallExpr{ Fun: &ast.SelectorExpr{ X: testDB, Sel: ast.NewIdent("GetAll" + inflection.Plural(tbl.GoTypeName)), }, }}, }, }, }, } testList := []ast.Decl{ makeItemFunc, testCreateUpdateDelete, testGetAll, } if shouldIncludeTestFkCheck { testList = append(testList, testFkChecking) } return &ast.File{ Name: ast.NewIdent(testpackageName), Decls: append([]ast.Decl{ &ast.GenDecl{ Tok: token.IMPORT, Specs: []ast.Spec{ &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}}, &ast.ImportSpec{ Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`}, Name: ast.NewIdent("."), }, &ast.ImportSpec{ Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`}, }, &ast.ImportSpec{ Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)}, Name: ast.NewIdent("."), }, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/go-test/deep"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}}, }, }, }, testList...), } }