21 Commits

Author SHA1 Message Date
616304c7dd ci: add support for manually triggering the build
All checks were successful
CI / build-docker (push) Successful in 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 4m14s
2026-07-12 14:05:05 -07:00
17fc8a68f6 codegen: wrap foreign key checks for nullable FKs in "if a.Val != 0 { ... }" 2026-07-12 13:34:21 -07:00
d572745613 codegen: don't skip created_at auto-timestamp for 'without rowid' tables 2026-07-12 13:20:35 -07:00
9bfb31798c codegen: don't auto-timestamp overwrite provided timestamps if there are ones 2026-07-07 12:15:51 -07:00
eafeb658bd codegen: fix invalid SQL query being generated for GetItemBy with multiple params 2026-06-24 13:59:13 -07:00
dbf14e23b6 codegen: fix fk checking lambda producing non-lint-passing code
All checks were successful
CI / build-docker (push) Successful in 4s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 2m42s
2026-05-29 17:34:40 -07:00
ad1782c73d codegen: use "require.NoError(...)" when saving objects that return errors 2026-05-29 15:36:36 -07:00
ccd7e32cbf codegen: test file now uses deep.Equal instead of comparing one field 2026-05-29 14:06:09 -07:00
ed4ade1956 codegen: fix escaped double-quote inside test strings 2026-05-29 13:44:13 -07:00
9a11f3986c codegen: improve the "TestFkChecking" generated function
All checks were successful
CI / build-docker (push) Successful in 6s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 3m22s
2026-04-24 14:05:48 -07:00
8c29d455ff codegen: fix defining the 'err' variable multiple times in foreign key checking test
All checks were successful
CI / build-docker (push) Successful in 12s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 41s
2026-03-19 15:43:31 -07:00
0f9a57dd85 codegen: fix more hardcoded "item" names in generated test file 2026-03-19 15:38:35 -07:00
11fed4b9c7 refactor: create CamelToPascal string helper 2026-03-19 15:35:18 -07:00
4e0836eb2e codegen: fix test generator hardcoding test string in multiple places
All checks were successful
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 15s
2026-03-18 13:54:43 -07:00
f82929f6e2 style: remove unused fmtErrorf var
All checks were successful
CI / build-docker (push) Successful in 15s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 27s
2026-03-18 11:00:18 -07:00
1bc7f9111f codegen: implement "without rowid" tables
Some checks failed
CI / build-docker (push) Successful in 6s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 16s
2026-03-18 10:39:38 -07:00
c1150954e5 doc: update README
All checks were successful
CI / build-docker (push) Successful in 4s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 19s
2026-03-16 11:45:07 -07:00
fd90830340 doc: add README.md
All checks were successful
CI / build-docker (push) Successful in 11s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 3m33s
2026-03-16 11:38:36 -07:00
fcf266eb1d tests: add integration test for blob fields
All checks were successful
CI / build-docker (push) Successful in 6s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 2m24s
2026-03-03 17:55:50 -08:00
29787b5521 fix: make codegen not fail for "blob" columns
- was previously using "[]byte" as a single `ast.NewIdent`, which made comment+blank-line-insertion reparsing fail
2026-03-03 17:53:47 -08:00
e53546a7f5 lint: fix several lll, wrapcheck, and copy-paste errors
All checks were successful
CI / build-docker (push) Successful in 8s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 30s
2026-02-22 21:58:06 -08:00
9 changed files with 692 additions and 414 deletions

View File

@@ -1,6 +1,6 @@
name: CI name: CI
on: [push] on: [push, workflow_dispatch]
jobs: jobs:
# These steps build the `gas` docker image. # These steps build the `gas` docker image.

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# GAS stack
## Compiling
Requires a Go compiler (minimum 1.22.5) and a C compiler, due to use of CGo.
```sh
git clone https://git.offline-twitter.com/offline-labs/gas-stack.git
cd gas-stack
go build -o gas -tags fts5 ./cmd
# Installation (optional)
sudo mv gas /usr/local/bin # ...or anywhere on your $PATH
which gas # should print "/usr/local/bin/gas"
```
## Using
The linter (`gas sqlite_lint`) is stable and useful.
The code generator is buggy, incomplete, and not remotely stable, but still quite useful. Don't expect it to produce perfectly working code, or even to compile correctly (e.g., you'll probably have to fix the imports). Copy-paste the parts that are useful, and delete the parts that aren't.
#### Linter
```sh
gas sqlite_lint <path/to/schema.sql>
```
#### Code generator
```sh
gas generate table_name # Generates a model
gas generate --test table_name # Optional: generates tests
```
It prints to the console. You can copy-paste the result. Or you can use bash redirection:
```sh
gas generate users > pkg/db/user.go
gas generate --test users > pkg/db/user_test.go
```
Useful flags:
- `--schema`: by default, `gas generate` assumes that the schema is at `pkg/db/schema.sql`. Use `gas generate --schema <path/to/schema.sql> [...]` to indicate otherwise

View File

@@ -46,7 +46,6 @@ var generate_model = &cobra.Command{
Specs: []ast.Spec{ Specs: []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"database/sql"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"database/sql"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"errors"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"errors"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
&ast.ImportSpec{ &ast.ImportSpec{
Name: ast.NewIdent("."), Name: ast.NewIdent("."),
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`}, Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
@@ -69,8 +68,16 @@ var generate_model = &cobra.Command{
modelgenerate.GenerateSQLFieldsConst(table), modelgenerate.GenerateSQLFieldsConst(table),
modelgenerate.GenerateSaveItemFunc(table), modelgenerate.GenerateSaveItemFunc(table),
modelgenerate.GenerateDeleteItemFunc(table), modelgenerate.GenerateDeleteItemFunc(table),
modelgenerate.GenerateGetItemByIDFunc(table),
) )
if table.IsWithoutRowid {
decls = append(decls,
modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()),
)
} else {
decls = append(decls,
modelgenerate.GenerateGetItemByIDFunc(table),
)
}
for _, index := range schema.Indexes { for _, index := range schema.Indexes {
if index.TableName != table.TableName { if index.TableName != table.TableName {
// Skip indexes on other tables // Skip indexes on other tables

View File

@@ -41,15 +41,24 @@ create table items (
rowid integer primary key, rowid integer primary key,
description text not null default '', description text not null default '',
flavor integer references item_flavor(rowid), flavor integer references item_flavor(rowid),
data blob not null,
thing text not null unique, thing text not null unique,
created_at integer not null, created_at integer not null,
updated_at integer not null updated_at integer not null
) strict; ) strict;
create table item_to_item (
item1_id integer references items(rowid),
item2_id integer references items(rowid),
primary key (item1_id, item2_id)
) strict, without rowid;
EOF EOF
# Generate an item model and test file # Generate an item model and test file
$gas generate items > pkg/db/item.go $gas generate items > pkg/db/item.go
$gas generate items --test > pkg/db/item_test.go $gas generate items --test > pkg/db/item_test.go
$gas generate item_to_item > pkg/db/item_to_item.go
go mod tidy go mod tidy
# Run the tests # Run the tests

View File

@@ -2,6 +2,7 @@ package modelgenerate
import ( import (
"bytes" "bytes"
"fmt"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/printer" "go/printer"
@@ -65,29 +66,14 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
var buf bytes.Buffer var buf bytes.Buffer
fset := token.NewFileSet() fset := token.NewFileSet()
if err := printer.Fprint(&buf, fset, file); err != nil { if err := printer.Fprint(&buf, fset, file); err != nil {
return err return fmt.Errorf("initial pretty-printing to get positioning: %w", err)
} }
// Re-parse to get real positions (ParseComments preserves doc comments) // Re-parse to get real positions (ParseComments preserves doc comments)
fset = token.NewFileSet() fset = token.NewFileSet()
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments) parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
if err != nil { if err != nil {
return err return fmt.Errorf("re-parsing pretty-print: %w", err)
}
// Convert the tree-of-nodes into a slice-of-nodes
collectNodes := func(node ast.Node) []ast.Node {
var nodes []ast.Node
ast.Inspect(node, func(n ast.Node) bool {
// Filter out comments, as they only appear in the
switch n.(type) {
case *ast.CommentGroup, *ast.Comment:
return false
}
nodes = append(nodes, n)
return true
})
return nodes
} }
// Parallel walk: apply TrailingComments from the side map. // Parallel walk: apply TrailingComments from the side map.
@@ -95,11 +81,33 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
// so ast.Inspect visits nodes in the same order. We skip comment nodes to // so ast.Inspect visits nodes in the same order. We skip comment nodes to
// avoid mismatches from Doc fields. // avoid mismatches from Doc fields.
if len(TrailingComments) > 0 { if len(TrailingComments) > 0 {
// Helper: convert the tree-of-nodes into a slice-of-nodes
collectNodes := func(node ast.Node) []ast.Node {
var nodes []ast.Node
ast.Inspect(node, func(n ast.Node) bool {
// Filter out comments, as they only appear in the
switch n.(type) {
case *ast.CommentGroup, *ast.Comment:
return false
}
nodes = append(nodes, n)
return true
})
return nodes
}
origNodes := collectNodes(file) origNodes := collectNodes(file)
reparsedNodes := collectNodes(parsed) reparsedNodes := collectNodes(parsed)
if len(origNodes) != len(reparsedNodes) {
panic(fmt.Sprintf(
"origNodes: %d; reparsedNodes: %d. The AST generator is likely generating an invalid AST",
len(origNodes), len(reparsedNodes),
))
}
for i, orig := range origNodes { for i, orig := range origNodes {
text, ok := TrailingComments[orig] text, isOk := TrailingComments[orig]
if !ok { if !isOk {
continue continue
} }
reparsed := reparsedNodes[i] reparsed := reparsedNodes[i]
@@ -110,40 +118,43 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
} }
extractCommentMarker := func(stmt ast.Stmt) (string, bool) { extractCommentMarker := func(stmt ast.Stmt) (string, bool) {
expr, ok := stmt.(*ast.ExprStmt) expr, isOk := stmt.(*ast.ExprStmt)
if !ok { if !isOk {
return "", false return "", false
} }
call, ok := expr.X.(*ast.CallExpr) call, isOk := expr.X.(*ast.CallExpr)
if !ok { if !isOk {
return "", false return "", false
} }
ident, ok := call.Fun.(*ast.Ident) ident, isOk := call.Fun.(*ast.Ident)
if !ok || ident.Name != commentMarker { if !isOk || ident.Name != commentMarker {
return "", false
}
lit, isOk := call.Args[0].(*ast.BasicLit)
if !isOk {
return "", false return "", false
} }
lit := call.Args[0].(*ast.BasicLit)
return lit.Value[1 : len(lit.Value)-1], true return lit.Value[1 : len(lit.Value)-1], true
} }
isBlankLineMarker := func(stmt ast.Stmt) bool { isBlankLineMarker := func(stmt ast.Stmt) bool {
expr, ok := stmt.(*ast.ExprStmt) expr, isOk := stmt.(*ast.ExprStmt)
if !ok { if !isOk {
return false return false
} }
call, ok := expr.X.(*ast.CallExpr) call, isOk := expr.X.(*ast.CallExpr)
if !ok { if !isOk {
return false return false
} }
ident, ok := call.Fun.(*ast.Ident) ident, isOk := call.Fun.(*ast.Ident)
return ok && ident.Name == blankLineMarker return isOk && ident.Name == blankLineMarker
} }
// Convert comment and blank-line markers // Convert comment and blank-line markers
ast.Inspect(parsed, func(n ast.Node) bool { ast.Inspect(parsed, func(n ast.Node) bool {
// We only care about Block nodes // We only care about Block nodes
block, ok := n.(*ast.BlockStmt) block, isOk := n.(*ast.BlockStmt)
if !ok { if !isOk {
return true return true
} }
@@ -151,7 +162,7 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
// in the block statement's body with actual ones // in the block statement's body with actual ones
filtered := block.List[:0] filtered := block.List[:0]
for _, stmt := range block.List { for _, stmt := range block.List {
if text, ok := extractCommentMarker(stmt); ok { if text, isOk := extractCommentMarker(stmt); isOk {
// If it's a comment, add it to the fileset's list of Comments // If it's a comment, add it to the fileset's list of Comments
parsed.Comments = append(parsed.Comments, &ast.CommentGroup{ parsed.Comments = append(parsed.Comments, &ast.CommentGroup{
List: []*ast.Comment{{Slash: stmt.Pos(), Text: "// " + text}}, List: []*ast.Comment{{Slash: stmt.Pos(), Text: "// " + text}},
@@ -176,5 +187,9 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
delete(TrailingComments, k) delete(TrailingComments, k)
} }
return printer.Fprint(w, fset, parsed) err = printer.Fprint(w, fset, parsed)
if err != nil {
return fmt.Errorf("re-pretty-printing: %w", err)
}
return nil
} }

View File

@@ -18,15 +18,57 @@ import (
// --------------- // ---------------
var ( var (
dbRecv = &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}} 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")} 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 { func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields") return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields")
} }
// GoTypeForColumn returns a type expression for this column.
//
// For most columns this isjust its mapped name as a `ast.NewIdent`, but for "blob" it needs
// a slice expression (`[]byte`).
func GoTypeForColumn(c schema.Column) ast.Expr {
if c.IsNonCodeTableForeignKey() {
return ast.NewIdent(schema.TypenameFromTablename(c.ForeignKeyTargetTable) + "ID")
}
switch c.Type {
case "integer", "int":
if strings.HasPrefix(c.Name, "is_") || strings.HasPrefix(c.Name, "has_") {
return ast.NewIdent("bool")
} else if strings.HasSuffix(c.Name, "_at") {
return ast.NewIdent("Timestamp")
}
return ast.NewIdent("int")
case "text":
return ast.NewIdent("string")
case "real":
return ast.NewIdent("float32")
case "blob":
return &ast.ArrayType{Elt: ast.NewIdent("byte")}
default:
panic("Unrecognized sqlite column type: " + c.Type)
}
}
func PanicIfRowsAffected(tbl schema.Table) *ast.IfStmt {
return &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&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.NewIdent(tbl.VarName)}}},
}},
}
}
// --------------- // ---------------
// Generators // Generators
// --------------- // ---------------
@@ -63,10 +105,9 @@ func GenerateModelAST(table schema.Table) *ast.GenDecl {
Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)}, Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)},
}) })
} else { } else {
typeName := col.GoTypeName()
fields = append(fields, &ast.Field{ fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(col.Name))}, Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(col.Name))},
Type: ast.NewIdent(typeName), Type: GoTypeForColumn(col),
Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)}, Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)},
}) })
} }
@@ -120,79 +161,92 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
structFieldName := col.GoFieldName() structFieldName := col.GoFieldName()
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)} structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
if col.IsNonCodeTableForeignKey() { ret = append(ret, func() ast.Stmt {
// Real foreign key; look up referent by ID to see if it exists // Wrap nullable FKs in "if a.val != 0 { ... }"
ret = append(ret, &ast.IfStmt{ wrap := func(input ast.Stmt) ast.Stmt {
Init: &ast.AssignStmt{ if col.IsNullableForeignKey() {
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")}, return &ast.IfStmt{
Tok: token.DEFINE, Cond: &ast.BinaryExpr{X: structField, Op: token.NEQ, Y: &ast.BasicLit{Kind: token.INT, Value: "0"}},
Rhs: []ast.Expr{ Body: &ast.BlockStmt{List: []ast.Stmt{input}},
&ast.CallExpr{ }
Fun: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent(getByIDFuncName(col.ForeignKeyTargetTable))}, } else {
Args: []ast.Expr{structField}, return input
}
}
if col.IsNonCodeTableForeignKey() {
// Real foreign key; look up referent by ID to see if it exists
return wrap(&ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent(getByIDFuncName(col.ForeignKeyTargetTable))},
Args: []ast.Expr{structField},
},
}, },
}, },
}, Cond: &ast.CallExpr{
Cond: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")}, Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")},
Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")}, },
}, Body: &ast.BlockStmt{
Body: &ast.BlockStmt{ List: []ast.Stmt{
List: []ast.Stmt{ &ast.ReturnStmt{
&ast.ReturnStmt{ Results: []ast.Expr{
Results: []ast.Expr{ &ast.CallExpr{
&ast.CallExpr{ Fun: ast.NewIdent("NewForeignKeyError"),
Fun: ast.NewIdent("NewForeignKeyError"), Args: []ast.Expr{
Args: []ast.Expr{ &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)}, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)}, structField,
structField, },
}, },
}, },
}, },
}, },
}, },
}, })
}) } else {
} else { // Code table value. Query the table to see if it exists
// Code table value. Query the table to see if it exists return wrap(&ast.IfStmt{
ret = append(ret, &ast.IfStmt{ Init: &ast.AssignStmt{
Init: &ast.AssignStmt{ Lhs: []ast.Expr{ast.NewIdent("err")},
Lhs: []ast.Expr{ast.NewIdent("err")}, Tok: token.DEFINE,
Tok: token.ASSIGN, Rhs: []ast.Expr{
Rhs: []ast.Expr{ &ast.CallExpr{
&ast.CallExpr{ Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")}, Args: []ast.Expr{
Args: []ast.Expr{ &ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
&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)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)}, structField,
structField, },
}, },
}, },
}, },
}, Cond: &ast.CallExpr{
Cond: &ast.CallExpr{ Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
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")}},
Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}}, },
}, Body: &ast.BlockStmt{
Body: &ast.BlockStmt{ List: []ast.Stmt{
List: []ast.Stmt{ &ast.ReturnStmt{
&ast.ReturnStmt{ Results: []ast.Expr{
Results: []ast.Expr{ &ast.CallExpr{
&ast.CallExpr{ Fun: ast.NewIdent("NewForeignKeyError"),
Fun: ast.NewIdent("NewForeignKeyError"), Args: []ast.Expr{
Args: []ast.Expr{ &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)}, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
ast.NewIdent(fmt.Sprintf("%q", col.ForeignKeyTargetTable)), structField,
structField, },
}, },
}, },
}, },
}, },
}, },
}, })
}) }
} }())
} }
// final return nil // final return nil
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}}) ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
@@ -229,10 +283,29 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
if col.Name == "created_at" && hasCreatedAt { if col.Name == "created_at" && hasCreatedAt {
continue continue
} }
updatePairs = append(updatePairs, col.Name+"="+val) if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
updatePairs = append(updatePairs, col.Name+"="+val)
}
}
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
tbl.TableName,
strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "),
)
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t",
tbl.TableName,
strings.Join(updatePairs, ",\n\t\t "),
)
upsertStmt := fmt.Sprintf("\n\t insert into %s (%s)\n\t values (%s)\n\t",
tbl.TableName,
strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "),
)
if len(updatePairs) == 0 {
upsertStmt = upsertStmt + " on conflict do nothing\n\t"
} else {
upsertStmt = upsertStmt + fmt.Sprintf(" on conflict do update\n\t set %s\n\t", strings.Join(updatePairs, ",\n\t "))
} }
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t", tbl.TableName, strings.Join(insertCols, ", "), strings.Join(insertVals, ", "))
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t", tbl.TableName, strings.Join(updatePairs, ",\n\t\t "))
checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl) checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl)
@@ -250,139 +323,161 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}}, Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
}) })
} }
// if item.ID == 0 {...} else {...}
ret = append(ret, &ast.IfStmt{ namedExecStmt := func(stmt string) []ast.Stmt {
Cond: &ast.BinaryExpr{ queryStmt := &ast.CallExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}, Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Op: token.EQL, Args: []ast.Expr{
Y: &ast.BasicLit{Kind: token.INT, Value: "0"}, &ast.BasicLit{Kind: token.STRING, Value: "`" + stmt + "`"},
}, ast.NewIdent(tbl.VarName),
Body: &ast.BlockStmt{ },
// Do create }
List: append( if !hasFks {
func() []ast.Stmt { // No foreign key checking needed; just use `Must` for brevity
ret1 := []ast.Stmt{Comment("Do create")} return []ast.Stmt{&ast.AssignStmt{
if hasCreatedAt { Lhs: []ast.Expr{ast.NewIdent("result")},
// Auto-timestamps: created_at Tok: token.DEFINE,
ret1 = append(ret1, &ast.AssignStmt{ Rhs: []ast.Expr{mustCall(queryStmt)},
}}
}
// There's foreign keys
return []ast.Stmt{
// result, err := db.DB.NamedExec(`...`, u)
&ast.AssignStmt{
Lhs: []ast.Expr{
ast.NewIdent("result"),
ast.NewIdent("err"),
},
Tok: token.DEFINE,
Rhs: []ast.Expr{queryStmt},
},
// if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) }
&ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("fkErr")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("checkForeignKeyFailures"),
Args: []ast.Expr{ast.NewIdent("err")},
},
},
},
Cond: &ast.BinaryExpr{
X: ast.NewIdent("fkErr"),
Op: token.NEQ,
Y: ast.NewIdent("nil"),
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{ast.NewIdent("fkErr")},
},
},
},
Else: func() *ast.IfStmt {
panicStmt := &ast.ExprStmt{
X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{ast.NewIdent("err")},
},
}
TrailingComments[panicStmt] = "not a foreign key error"
return &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: ast.NewIdent("err"),
Op: token.NEQ,
Y: ast.NewIdent("nil"),
},
Body: &ast.BlockStmt{
List: []ast.Stmt{panicStmt},
},
}
}(),
},
}
}
if tbl.IsWithoutRowid {
if hasCreatedAt {
// Auto-timestamps: created_at. Don't overwrite existing timestamps (e.g., data import / migrations)
ret = append(ret, &ast.IfStmt{
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")},
Sel: ast.NewIdent("IsZero"),
}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}}, Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN, Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}}, Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
namedExecStmt := &ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"},
ast.NewIdent(tbl.VarName),
}, },
}
if !hasFks {
// No foreign key checking needed; just use `Must` for brevity
return append(ret1, &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(namedExecStmt)},
})
}
return append(ret1,
// result, err := db.DB.NamedExec(`...`, u)
&ast.AssignStmt{
Lhs: []ast.Expr{
ast.NewIdent("result"),
ast.NewIdent("err"),
},
Tok: token.DEFINE,
Rhs: []ast.Expr{namedExecStmt},
},
// if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) }
&ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("fkErr")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("checkForeignKeyFailures"),
Args: []ast.Expr{ast.NewIdent("err")},
},
},
},
Cond: &ast.BinaryExpr{
X: ast.NewIdent("fkErr"),
Op: token.NEQ,
Y: ast.NewIdent("nil"),
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{ast.NewIdent("fkErr")},
},
},
},
Else: func() *ast.IfStmt {
panicStmt := &ast.ExprStmt{
X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{ast.NewIdent("err")},
},
}
TrailingComments[panicStmt] = "not a foreign key error"
return &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: ast.NewIdent("err"),
Op: token.NEQ,
Y: ast.NewIdent("nil"),
},
Body: &ast.BlockStmt{
List: []ast.Stmt{panicStmt},
},
}
}(),
},
)
}(),
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent(tbl.TypeIDName),
Args: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")},
Args: []ast.Expr{},
})},
}},
},
),
},
Else: &ast.BlockStmt{
// Do update
List: []ast.Stmt{
Comment("Do update"),
&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")},
Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: "`" + updateStmt + "`"}, ast.NewIdent(tbl.VarName)},
})},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&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")}}}}}}}},
}, },
})
}
ret = append(ret, namedExecStmt(upsertStmt)...)
ret = append(ret, PanicIfRowsAffected(tbl))
} else {
// if item.ID == 0 {...} else {...}
ret = append(ret, &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
Op: token.EQL,
Y: &ast.BasicLit{Kind: token.INT, Value: "0"},
}, },
}, Body: &ast.BlockStmt{
}) // Do create
List: append(
func() []ast.Stmt {
ret1 := []ast.Stmt{Comment("Do create")}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &ast.IfStmt{
// Don't overwrite existing timestamps. This is useful for various reasons, e.g., data import / migrations
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")},
Sel: ast.NewIdent("IsZero"),
}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&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{}}},
},
},
},
})
}
return append(ret1, namedExecStmt(insertStmt)...)
}(),
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent(tbl.TypeIDName),
Args: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")},
Args: []ast.Expr{},
})},
}},
},
),
},
Else: &ast.BlockStmt{
// Do update
List: append(
[]ast.Stmt{Comment("Do update")},
append(
namedExecStmt(updateStmt),
PanicIfRowsAffected(tbl),
)...,
),
},
})
}
if hasFks { if hasFks {
// If there's foreign key checking, it needs to return an error (or nil) // If there's foreign key checking, it needs to return an error (or nil)
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}}) ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
@@ -416,6 +511,62 @@ func getByIDFuncName(tblname string) string {
return "Get" + schema.TypenameFromTablename(tblname) + "ByID" return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
} }
func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
colNames := []string{}
funcNameSuffix := []string{}
funcParams := &ast.FieldList{List: []*ast.Field{}}
sqlParams := []ast.Expr{}
for _, col := range cols {
funcParam := ast.NewIdent(col.LongGoVarName())
funcParams.List = append(funcParams.List, &ast.Field{Names: []*ast.Ident{funcParam}, Type: GoTypeForColumn(col)})
colNames = append(colNames, fmt.Sprintf("%s = ?", col.Name))
funcNameSuffix = append(funcNameSuffix, col.GoFieldName())
sqlParams = append(sqlParams, funcParam)
}
selectExpr := &ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`\n\t select `"},
Op: token.ADD,
Y: SQLFieldsConstIdent(tbl),
},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`\n\t from %s\n\t where %s\n\t`", tbl.TableName, strings.Join(colNames, " and "))},
}
return &ast.FuncDecl{
Recv: dbRecv,
Name: ast.NewIdent(fmt.Sprintf("Get%sBy%s", schema.TypenameFromTablename(tbl.TableName), strings.Join(funcNameSuffix, "And"))),
Type: &ast.FuncType{
Params: funcParams,
Results: &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")},
}},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&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: append([]ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr}, sqlParams...),
}},
},
&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")}},
},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ReturnStmt{Results: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent(tbl.GoTypeName)}, ast.NewIdent("ErrNotInDB")}}}},
},
&ast.ReturnStmt{},
},
},
}
}
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function. // GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function. // E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl { func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
@@ -448,16 +599,15 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
}, },
} }
funcDecl := &ast.FuncDecl{ return &ast.FuncDecl{
Recv: dbRecv, Recv: dbRecv,
Name: ast.NewIdent(getByIDFuncName(tbl.TableName)), Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
Type: &ast.FuncType{Params: arg, Results: result}, Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody, Body: funcBody,
} }
return funcDecl
} }
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function. // GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function. // E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl { func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
// Use the xyzSQLFields constant in the select query // Use the xyzSQLFields constant in the select query
@@ -478,7 +628,7 @@ func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.Func
Name: ast.NewIdent("Get" + schema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName()), Name: ast.NewIdent("Get" + schema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName()),
Type: &ast.FuncType{ Type: &ast.FuncType{
Params: &ast.FieldList{List: []*ast.Field{ Params: &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{param}, Type: ast.NewIdent(col.GoTypeName())}, {Names: []*ast.Ident{param}, Type: GoTypeForColumn(col)},
}}, }},
Results: &ast.FieldList{List: []*ast.Field{ Results: &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: ast.NewIdent(tbl.GoTypeName)}, {Names: []*ast.Ident{ast.NewIdent("ret")}, Type: ast.NewIdent(tbl.GoTypeName)},
@@ -555,10 +705,12 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
// GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function. // GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function. // E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function.
func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl { func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
arg := &ast.FieldList{List: []*ast.Field{{ colNames := []string{}
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, for _, c := range tbl.PrimaryKeyColumns() {
Type: ast.NewIdent(tbl.GoTypeName), colNames = append(colNames, fmt.Sprintf("%s = :%s", c.Name, c.Name))
}}} }
sqlStr := "`delete from " + tbl.TableName + fmt.Sprintf(" where %s`", strings.Join(colNames, " and "))
funcBody := &ast.BlockStmt{ funcBody := &ast.BlockStmt{
List: []ast.Stmt{ List: []ast.Stmt{
@@ -566,41 +718,24 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
Lhs: []ast.Expr{ast.NewIdent("result")}, Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{ Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Exec")}, Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{ Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"}, &ast.BasicLit{Kind: token.STRING, Value: sqlStr},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}, ast.NewIdent(tbl.VarName),
}, },
})}, })},
}, },
&ast.IfStmt{ PanicIfRowsAffected(tbl),
Cond: &ast.BinaryExpr{
X: mustCall(
&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("\"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")},
},
}},
}},
}},
},
}, },
} }
funcDecl := &ast.FuncDecl{ funcDecl := &ast.FuncDecl{
Recv: dbRecv, Recv: dbRecv,
Name: ast.NewIdent("Delete" + tbl.GoTypeName), Name: ast.NewIdent("Delete" + tbl.GoTypeName),
Type: &ast.FuncType{Params: arg, Results: nil}, Type: &ast.FuncType{Params: &ast.FieldList{List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)},
Type: ast.NewIdent(tbl.GoTypeName),
}}}, Results: nil},
Body: funcBody, Body: funcBody,
} }
return funcDecl return funcDecl

View File

@@ -8,6 +8,7 @@ import (
"github.com/jinzhu/inflection" "github.com/jinzhu/inflection"
pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema" pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
) )
// GenerateModelTestAST produces an AST for a starter test file for a given model. // GenerateModelTestAST produces an AST for a starter test file for a given model.
@@ -15,9 +16,11 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
packageName := "db" packageName := "db"
testpackageName := packageName + "_test" testpackageName := packageName + "_test"
makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName)
// func MakeItem() Item { return Item{} } // func MakeItem() Item { return Item{} }
makeItemFunc := &ast.FuncDecl{ makeItemFunc := &ast.FuncDecl{
Name: ast.NewIdent("Make" + tbl.GoTypeName), Name: makeHelperName,
Type: &ast.FuncType{ Type: &ast.FuncType{
Params: &ast.FieldList{}, Params: &ast.FieldList{},
Results: &ast.FieldList{ Results: &ast.FieldList{
@@ -32,6 +35,24 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Results: []ast.Expr{ Results: []ast.Expr{
&ast.CompositeLit{ &ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName), Type: ast.NewIdent(tbl.GoTypeName),
Elts: []ast.Expr{
&ast.KeyValueExpr{
Key: ast.NewIdent("Data"),
Value: &ast.CompositeLit{
Type: &ast.ArrayType{
Elt: ast.NewIdent("byte"),
},
Elts: []ast.Expr{},
},
},
&ast.KeyValueExpr{
Key: ast.NewIdent("Description"),
Value: &ast.BasicLit{
Kind: token.STRING,
Value: `""`,
},
},
},
}, },
}, },
}, },
@@ -39,15 +60,55 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
} }
testObj := ast.NewIdent("item") testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
testObj2 := ast.NewIdent("item2") testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
fieldName := ast.NewIdent("Description") fieldName := ast.NewIdent("Description") // TODO
description1 := `"an item"` description1 := "an item"
description2 := `"a big item"` description2 := "a big item"
testDB := ast.NewIdent("TestDB") testDB := ast.NewIdent("TestDB")
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
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{ testFuncType := &ast.FuncType{
Params: &ast.FieldList{ Params: &ast.FieldList{
List: []*ast.Field{{ List: []*ast.Field{{
@@ -57,6 +118,103 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
} }
// 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{ testCreateUpdateDelete := &ast.FuncDecl{
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName), Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
Type: testFuncType, Type: testFuncType,
@@ -72,26 +230,44 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
stmts := []ast.Stmt{ stmts := []ast.Stmt{
Comment("Create"), Comment("Create"),
// item := Item{Description: "an item"} // item := MakeItem()
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{testObj}, Lhs: []ast.Expr{testObj},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CompositeLit{ Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}},
Type: ast.NewIdent(tbl.GoTypeName), },
Elts: []ast.Expr{ // item.Description = "an item"
&ast.KeyValueExpr{ &ast.AssignStmt{
Key: fieldName, Lhs: []ast.Expr{
Value: &ast.BasicLit{Kind: token.STRING, Value: description1}, &ast.SelectorExpr{
}, X: testObj,
Sel: ast.NewIdent("Description"),
}, },
}}, },
Tok: token.ASSIGN,
Rhs: []ast.Expr{
&ast.BasicLit{
Kind: token.STRING,
Value: fmt.Sprintf("%q", description1),
},
},
}, },
// TestDB.SaveItem(&item) // TestDB.SaveItem(&item), possibly with error check
&ast.ExprStmt{X: &ast.CallExpr{ &ast.ExprStmt{X: func() *ast.CallExpr {
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)}, mainExpr := &ast.CallExpr{
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}}, 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) // require.NotZero(t, item.ID)
&ast.ExprStmt{X: &ast.CallExpr{ &ast.ExprStmt{X: &ast.CallExpr{
@@ -122,15 +298,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
})}, })},
}, },
// assert.Equal(t, "an item", item2.Description) // if deep.Equal(...) {...}
&ast.ExprStmt{X: &ast.CallExpr{ makeDeepEqual(testObj, testObj2),
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Equal")},
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.BasicLit{Kind: token.STRING, Value: description1},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
) )
stmts = append(stmts, stmts = append(stmts,
@@ -141,7 +310,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}}, Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
Tok: token.ASSIGN, Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}}, Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", description2)}},
}, },
// TestDB.SaveItem(&item) // TestDB.SaveItem(&item)
@@ -160,15 +329,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
})}, })},
}, },
// assert.Equal(t, item.Description, item2.Description) // if deep.Equal(...) {...}
&ast.ExprStmt{X: &ast.CallExpr{ makeDeepEqual(testObj, testObj2),
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Equal")},
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.SelectorExpr{X: testObj, Sel: fieldName},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
) )
indexGets, hasIndexedGets := []ast.Stmt{ indexGets, hasIndexedGets := []ast.Stmt{
@@ -191,7 +353,9 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
ast.NewIdent("t"), ast.NewIdent("t"),
testObj2, testObj2,
mustCall(&ast.CallExpr{ mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName())}, 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())}}, Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}},
}), }),
}, },
@@ -261,92 +425,6 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
} }
shouldIncludeTestFkCheck := false
testFkChecking := &ast.FuncDecl{
Name: ast.NewIdent("Test" + tbl.GoTypeName + "FkChecking"),
Type: testFuncType,
Body: &ast.BlockStmt{
List: func() []ast.Stmt {
// post := MakePost()
stmts := []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent(tbl.VarName)},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("Make" + tbl.GoTypeName),
},
},
},
}
for _, col := range tbl.Columns {
if col.IsForeignKey {
shouldIncludeTestFkCheck = true
stmts = append(stmts, []ast.Stmt{
// 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: token.DEFINE,
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()),
},
},
},
},
}...)
}
}
return stmts
}(),
},
}
testList := []ast.Decl{ testList := []ast.Decl{
makeItemFunc, makeItemFunc,
testCreateUpdateDelete, testCreateUpdateDelete,
@@ -374,6 +452,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)}, Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
Name: ast.NewIdent("."), 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/assert"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
}, },

View File

@@ -52,32 +52,13 @@ func (c Column) GoVarName() string {
return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID" return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID"
} }
// Otherwise, just lowercase the field name // Otherwise, just use the whole name
fieldname := c.GoFieldName() return c.LongGoVarName()
return strings.ToLower(fieldname)[0:1] + fieldname[1:]
} }
func (c Column) GoTypeName() string { // LongGoVarName returns a lowercased version of the field name (Pascal => Camel).
if c.IsNonCodeTableForeignKey() { func (c Column) LongGoVarName() string {
return TypenameFromTablename(c.ForeignKeyTargetTable) + "ID" return textutils.CamelToPascal(c.GoFieldName())
}
switch c.Type {
case "integer", "int":
if strings.HasPrefix(c.Name, "is_") || strings.HasPrefix(c.Name, "has_") {
return "bool"
} else if strings.HasSuffix(c.Name, "_at") {
return "Timestamp"
}
return "int"
case "text":
return "string"
case "real":
return "float32"
case "blob":
return "[]byte"
default:
panic("Unrecognized sqlite column type: " + c.Type)
}
} }
// Table is a single SQLite table. // Table is a single SQLite table.

View File

@@ -9,3 +9,7 @@ func SnakeToCamel(s string) string {
} }
return strings.Join(parts, "") return strings.Join(parts, "")
} }
func CamelToPascal(s string) string {
return strings.ToLower(s)[0:1] + s[1:]
}