Compare commits

..

No commits in common. "04676461ff333c2fce84301b910653dc17b09035" and "5973a2a4b732de414c376b42557650abe9145755" have entirely different histories.

6 changed files with 187 additions and 220 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@ sample_data/data
# Legacy versions
.cmd-old/
pkg/.testapp
.claude

View File

@ -26,14 +26,6 @@ TODO: language-server-for-TODO.txt
TODO: auto-migration-checker
- Use `pkg/schema` to test whether a base schema plus a migration equals a new schema
TODO: codegen SaveXyz update path doesn't check foreign keys
- Insert path has FK error handling, but the update path wraps everything in Must
- An update that violates an FK constraint will panic instead of returning an error
TODO: codegen `without rowid` tables properly
TODO: generated test file inclues global test DB setup, which is wrong
TODO: join-tables
- handle codegen for without rowid tables properly
- add "get all "

View File

@ -7,7 +7,6 @@ sudo docker run --rm -it \
-v "$(go env GOMODCACHE):/gocache-vol/mod-cache" \
-e GOMODCACHE=/gocache-vol/mod-cache \
-e GOLANGCI_LINT_CACHE=/gocache-vol/lint-cache \
-v /memory:/memory \
--workdir /code \
--net host \
gas

View File

@ -1,11 +0,0 @@
package modelgenerate
import "go/ast"
// mustCall wraps a call expression in Must(...), producing AST for Must(inner).
func mustCall(inner ast.Expr) *ast.CallExpr {
return &ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{inner},
}
}

View File

@ -13,18 +13,12 @@ import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
// ---------------
// Helpers
// ---------------
var (
dbRecv = &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
dbDB = &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("DB")}
fmtErrorf = &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Errorf")}
)
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields")
func GenerateIDType(table schema.Table) *ast.GenDecl {
// e.g., `type FoodID int`
return &ast.GenDecl{
Tok: token.TYPE,
Specs: []ast.Spec{&ast.TypeSpec{Name: ast.NewIdent(table.TypeIDName), Type: ast.NewIdent("int")}},
}
}
func fkFieldName(col schema.Column) string {
@ -35,19 +29,6 @@ func fkFieldName(col schema.Column) string {
}
}
// ---------------
// Generators
// ---------------
// GenerateIDType produces an AST for the model's ID field.
func GenerateIDType(table schema.Table) *ast.GenDecl {
// e.g., `type FoodID int`
return &ast.GenDecl{
Tok: token.TYPE,
Specs: []ast.Spec{&ast.TypeSpec{Name: ast.NewIdent(table.TypeIDName), Type: ast.NewIdent("int")}},
}
}
// GenerateModelAST produces an AST for a struct type corresponding to the model.
// TODO: generate the right field types here based on column types.
func GenerateModelAST(table schema.Table) *ast.GenDecl {
@ -222,7 +203,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
Tok: token.ASSIGN,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
Fun: &ast.SelectorExpr{X: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("DB")}, Sel: ast.NewIdent("Get")},
Args: []ast.Expr{
&ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)},
@ -288,17 +269,17 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
// Do create
List: append(
func() []ast.Stmt {
ret1 := []ast.Stmt{}
ret := []ast.Stmt{}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &ast.AssignStmt{
ret = append(ret, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
namedExecStmt := &ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"},
ast.NewIdent(tbl.VarName),
@ -306,14 +287,17 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}
if !hasFks {
// No foreign key checking needed; just use `Must` for brevity
return append(ret1, &ast.AssignStmt{
return append(ret, &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(namedExecStmt)},
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{namedExecStmt},
}},
})
}
return append(ret1,
return append(ret,
// result, err := db.DB.NamedExec(`...`, u)
&ast.AssignStmt{
Lhs: []ast.Expr{
@ -373,10 +357,13 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent(tbl.TypeIDName),
Args: []ast.Expr{mustCall(&ast.CallExpr{
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")},
Args: []ast.Expr{},
})},
}},
}},
}},
},
),
@ -387,22 +374,28 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: "`" + updateStmt + "`"}, ast.NewIdent(tbl.VarName)},
})},
}},
}},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&ast.CallExpr{
X: &ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
Args: []ast.Expr{},
}),
}},
},
Op: token.NEQ,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ExprStmt{X: &ast.CallExpr{Fun: ast.NewIdent("panic"), Args: []ast.Expr{&ast.CallExpr{Fun: fmtErrorf, Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("\"got %s with ID (%%d), so attempted update, but it doesn't exist\"", strings.ToLower(tbl.GoTypeName))}, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}}}}}}}},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ExprStmt{X: &ast.CallExpr{Fun: ast.NewIdent("panic"), Args: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("fmt.Errorf"), Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("\"got %s with ID (%%d), so attempted update, but it doesn't exist\"", strings.ToLower(tbl.GoTypeName))}, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}}}}}}}},
},
},
},
@ -416,7 +409,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}
funcDecl := &ast.FuncDecl{
Recv: dbRecv,
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}},
Name: ast.NewIdent("Save" + tbl.GoTypeName),
Type: &ast.FuncType{
Params: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)}}}},
@ -439,6 +432,7 @@ func getByIDFuncName(tblname string) string {
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
recv := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
arg := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("id")}, Type: ast.NewIdent(tbl.TypeIDName)}}}
result := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: ast.NewIdent(tbl.GoTypeName)}, {Names: []*ast.Ident{ast.NewIdent("err")}, Type: ast.NewIdent("error")}}}
@ -458,7 +452,7 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("err")},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")}, Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr, ast.NewIdent("id")}}},
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("Get")}, Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr, ast.NewIdent("id")}}},
},
&ast.IfStmt{
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")}, Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}}},
@ -469,7 +463,7 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
}
funcDecl := &ast.FuncDecl{
Recv: dbRecv,
Recv: recv,
Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody,
@ -481,6 +475,9 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
funcName := "GetAll" + inflection.Plural(tbl.GoTypeName)
recv := &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")},
}}
result := &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
}}
@ -490,7 +487,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
Args: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: dbDB,
X: ast.NewIdent("db.DB"),
Sel: ast.NewIdent("Select"),
},
Args: []ast.Expr{
@ -517,7 +514,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
}
return &ast.FuncDecl{
Recv: dbRecv,
Recv: recv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{
Params: &ast.FieldList{},
@ -531,6 +528,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
// E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function.
func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
funcName := "Delete" + tbl.GoTypeName
recv := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
arg := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: ast.NewIdent(tbl.GoTypeName)}}}
funcBody := &ast.BlockStmt{
@ -538,19 +536,25 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Exec")},
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("Exec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
},
})},
}},
}},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(
X: &ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")}, Args: []ast.Expr{}},
),
},
},
Op: token.NEQ,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
@ -558,7 +562,7 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{&ast.CallExpr{
Fun: fmtErrorf,
Fun: ast.NewIdent("fmt.Errorf"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("\"tried to delete %s with ID (%%d) but it doesn't exist\"", strings.ToLower(tbl.GoTypeName))},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
@ -571,7 +575,7 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
}
funcDecl := &ast.FuncDecl{
Recv: dbRecv,
Recv: recv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{Params: arg, Results: nil},
Body: funcBody,
@ -602,3 +606,11 @@ func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
},
}
}
// ---------------
// Helpers
// ---------------
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields")
}

View File

@ -61,7 +61,9 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("db")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Create"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("fmt.Sprintf"),
@ -70,7 +72,8 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
ast.NewIdent("dbName"),
},
}},
})},
}},
}},
},
// return db
&ast.ReturnStmt{
@ -110,8 +113,6 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
description1 := `"an item"`
description2 := `"a big item"`
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
testCreateUpdateDelete := &ast.FuncDecl{
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
Type: &ast.FuncType{
@ -123,15 +124,7 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
},
},
Body: &ast.BlockStmt{
List: func() []ast.Stmt {
assertNotZero := func(obj *ast.Ident, field string) *ast.ExprStmt {
return &ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.NotZero"),
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(field)}},
}}
}
stmts := []ast.Stmt{
List: []ast.Stmt{
// item := Item{Description: "an item"}
&ast.AssignStmt{
Lhs: []ast.Expr{testObj},
@ -158,25 +151,18 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
Fun: ast.NewIdent("require.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,
// item2 := Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
})},
}},
}},
},
// assert.Equal(t, "an item", item2.Description)
@ -201,25 +187,18 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
Fun: ast.NewIdent("TestDB.Save" + tbl.GoTypeName),
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
)
// After update: assert timestamps are still set
if hasCreatedAt {
stmts = append(stmts, assertNotZero(testObj, "CreatedAt"))
}
if hasUpdatedAt {
stmts = append(stmts, assertNotZero(testObj, "UpdatedAt"))
}
stmts = append(stmts,
// item2 = Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
})},
}},
}},
},
// assert.Equal(t, item.Description, item2.Description)
@ -257,10 +236,7 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
ast.NewIdent("ErrNotInDB"),
},
}},
)
return stmts
}(),
},
},
}