Compare commits

...

8 Commits

Author SHA1 Message Date
1c56661560 codegen: add foreign key error check test
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 17s
2026-02-01 08:04:12 -08:00
3d357abb93 gitignore: ignore temporary / legacy files 2026-01-31 20:36:57 -08:00
wispem-wantex
a3da7573c1 codegen: slop out an AST generator for 'checkForeignKeyErrors' in 'SaveXyz()'
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 13s
2026-01-31 18:29:25 -08:00
wispem-wantex
b96ab19bc2 refactor: create helper functions in model generator
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 2m25s
2026-01-31 18:25:53 -08:00
wispem-wantex
cb8edd74c0 codegen: fix whitespace error in generated SQL 2026-01-29 10:54:58 -08:00
wispem-wantex
a0d0461f06 codegen: handle nullable foreign keys using 'ifnull' and add a test case for them
All checks were successful
CI / build-docker (push) Successful in 18s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 1m2s
2026-01-26 16:00:19 -08:00
wispem-wantex
e85a68e69d devcontainer: make it work on mac
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 2m17s
2026-01-26 14:54:59 -08:00
wispem-wantex
eee6714918 codegen: style fixes ('int64' -> 'int', 'SELECT' -> 'select') 2026-01-26 14:53:04 -08:00
9 changed files with 454 additions and 48 deletions

4
.gitignore vendored
View File

@ -1 +1,5 @@
sample_data/data sample_data/data
# Legacy versions
.cmd-old/
pkg/.testapp

View File

@ -15,3 +15,17 @@ TODO: generator-foreign-keys
TODO: migration-structs TODO: migration-structs
- Right now, migrations are strings. Could be a struct with "name", "up" and "down" fields - Right now, migrations are strings. Could be a struct with "name", "up" and "down" fields
- Adding a "down" operation enables handling newer DB versions with "down instead of error-out" for development (perhaps a flag) - Adding a "down" operation enables handling newer DB versions with "down instead of error-out" for development (perhaps a flag)
IDEA: migrations-table
- Store migrations in a table. This makes the schema more self-documenting.
- Possible schema: name, sql_up, sql_down, hash (computed from those fields plus previous migration hash)
- or just rowid instead of hash? Migration sequence should be immutable after publishing, so there should never be "conflicts"
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 `without rowid` tables properly
TODO: generated test file inclues global test DB setup, which is wrong

View File

@ -10,6 +10,8 @@ RUN apk add less
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2 RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2 RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
COPY etc/group /etc/group
# Create a user in the container with the same UID as on the host machine, to avoid ownership conflicts. # Create a user in the container with the same UID as on the host machine, to avoid ownership conflicts.
# The user gets sudo of course. # The user gets sudo of course.
# #

View File

@ -0,0 +1,3 @@
root:x:0:
nogroup:x:65533:
nobody:x:65534:

View File

@ -9,7 +9,8 @@ set -e
set -x set -x
PS4='+(${BASH_SOURCE}:${LINENO}): ' PS4='+(${BASH_SOURCE}:${LINENO}): '
cd "$(dirname "${BASH_SOURCE[0]}")/.." proj_root=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..")
cd "$proj_root"
# Compile `gas` # Compile `gas`
gas="/tmp/gas" gas="/tmp/gas"
@ -29,11 +30,17 @@ EOF
cd $test_project cd $test_project
# Add "replace" directive"
echo "replace git.offline-twitter.com/offline-labs/gas-stack => $proj_root" >> go.mod
# Create a new table in the schema # Create a new table in the schema
cat >> pkg/db/schema.sql <<EOF cat >> pkg/db/schema.sql <<EOF
create table item_flavor (rowid integer primary key, name text not null) strict;
create table items ( 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)
) strict; ) strict;
EOF EOF

View File

@ -21,6 +21,14 @@ func GenerateIDType(table schema.Table) *ast.GenDecl {
} }
} }
func fkFieldName(col schema.Column) string {
if col.IsNonCodeTableForeignKey() {
return textutils.SnakeToCamel(strings.TrimSuffix(col.Name, "_id")) + "ID"
} else {
return textutils.SnakeToCamel(col.Name)
}
}
// GenerateModelAST produces an AST for a struct type corresponding to the model. // GenerateModelAST produces an AST for a struct type corresponding to the model.
// TODO: generate the right field types here based on column types. // TODO: generate the right field types here based on column types.
func GenerateModelAST(table schema.Table) *ast.GenDecl { func GenerateModelAST(table schema.Table) *ast.GenDecl {
@ -37,9 +45,9 @@ func GenerateModelAST(table schema.Table) *ast.GenDecl {
Tag: &ast.BasicLit{Kind: token.STRING, Value: "`db:\"rowid\" json:\"id\"`"}, Tag: &ast.BasicLit{Kind: token.STRING, Value: "`db:\"rowid\" json:\"id\"`"},
}) })
default: default:
if col.IsForeignKey && strings.HasSuffix(col.Name, "_id") { if col.IsNonCodeTableForeignKey() {
fields = append(fields, &ast.Field{ fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(strings.TrimSuffix(col.Name, "_id")) + "ID")}, Names: []*ast.Ident{ast.NewIdent(fkFieldName(col))},
Type: ast.NewIdent(schema.TypenameFromTablename(col.ForeignKeyTargetTable) + "ID"), Type: ast.NewIdent(schema.TypenameFromTablename(col.ForeignKeyTargetTable) + "ID"),
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)},
}) })
@ -52,7 +60,7 @@ func GenerateModelAST(table schema.Table) *ast.GenDecl {
} else if strings.HasSuffix(col.Name, "_at") { } else if strings.HasSuffix(col.Name, "_at") {
typeName = "Timestamp" typeName = "Timestamp"
} else { } else {
typeName = "int64" typeName = "int"
} }
case "text": case "text":
typeName = "string" typeName = "string"
@ -102,33 +110,227 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
updatePairs = append(updatePairs, col.Name+"="+val) updatePairs = append(updatePairs, col.Name+"="+val)
} }
hasFks := false
checkForeignKeyFailuresAssignment := &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("checkForeignKeyFailures")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.FuncLit{
Type: &ast.FuncType{
Params: &ast.FieldList{
List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent("err")},
Type: ast.NewIdent("error"),
}},
},
Results: &ast.FieldList{
List: []*ast.Field{{Type: ast.NewIdent("error")}},
},
},
Body: &ast.BlockStmt{
List: func() []ast.Stmt {
ret := []ast.Stmt{}
// if !isSqliteFkError(err) { return nil }
ret = append(ret, &ast.IfStmt{
Cond: &ast.UnaryExpr{Op: token.NOT, X: &ast.CallExpr{Fun: ast.NewIdent("IsSqliteFkError"), Args: []ast.Expr{ast.NewIdent("err")}}},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}}}},
})
for _, col := range tbl.Columns {
if !col.IsForeignKey { // Check both "real" foreign keys and code table values
continue
}
hasFks = true
structFieldName := fkFieldName(col)
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
if col.IsNonCodeTableForeignKey() {
// Real foreign key; look up referent by ID to see if it exists
ret = append(ret, &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{
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Args: []ast.Expr{
ast.NewIdent("err"),
ast.NewIdent("ErrNotInDB"),
},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
structField,
},
},
},
},
},
},
})
} else {
// Code table value. Query the table to see if it exists
ret = append(ret, &ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{
ast.NewIdent("err"),
},
Tok: token.ASSIGN,
Rhs: []ast.Expr{
&ast.CallExpr{
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)},
structField,
},
},
},
},
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.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
ast.NewIdent(fmt.Sprintf("%q", col.ForeignKeyTargetTable)),
structField,
},
},
},
},
},
},
})
}
}
// final return nil
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
return ret
}(),
},
},
},
}
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, ", ")) 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 ")) 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 "))
funcBody := &ast.BlockStmt{ funcBody := &ast.BlockStmt{
List: []ast.Stmt{ List: func() []ast.Stmt {
&ast.IfStmt{ ret := []ast.Stmt{}
if hasFks {
ret = append(ret, checkForeignKeyFailuresAssignment)
}
// if item.ID == 0 {...} else {...}
ret = append(ret, &ast.IfStmt{
Cond: &ast.BinaryExpr{ Cond: &ast.BinaryExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}, X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
Op: token.EQL, Op: token.EQL,
Y: &ast.BasicLit{Kind: token.INT, Value: "0"}, Y: &ast.BasicLit{Kind: token.INT, Value: "0"},
}, },
Body: &ast.BlockStmt{ Body: &ast.BlockStmt{
List: []ast.Stmt{ // Do create
&ast.AssignStmt{ List: append(
Lhs: []ast.Expr{ast.NewIdent("result")}, func() []ast.Stmt {
Tok: token.DEFINE, namedExecStmt := &ast.CallExpr{
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")}, Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{ Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"}, &ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"},
ast.NewIdent(tbl.VarName), ast.NewIdent(tbl.VarName),
}, },
}}, }
if !hasFks {
// No foreign key checking needed; just use `Must` for brevity
return []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{namedExecStmt},
}}, }},
}, },
}
}
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{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: &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: ast.NewIdent("err"),
Op: token.NEQ,
Y: ast.NewIdent("nil"),
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ExprStmt{
X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{ast.NewIdent("err")},
},
},
},
},
},
},
}
}(),
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}}, Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}},
Tok: token.ASSIGN, Tok: token.ASSIGN,
@ -143,9 +345,10 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}}, }},
}}, }},
}, },
}, ),
}, },
Else: &ast.BlockStmt{ Else: &ast.BlockStmt{
// Do update
List: []ast.Stmt{ List: []ast.Stmt{
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")}, Lhs: []ast.Expr{ast.NewIdent("result")},
@ -175,8 +378,13 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}, },
}, },
}, },
}, })
}, if hasFks {
// 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")}})
}
return ret
}(),
} }
funcDecl := &ast.FuncDecl{ funcDecl := &ast.FuncDecl{
@ -184,18 +392,25 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
Name: ast.NewIdent("Save" + tbl.GoTypeName), Name: ast.NewIdent("Save" + tbl.GoTypeName),
Type: &ast.FuncType{ Type: &ast.FuncType{
Params: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)}}}}, Params: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)}}}},
Results: nil, Results: func() *ast.FieldList {
if hasFks {
return &ast.FieldList{List: []*ast.Field{{Type: ast.NewIdent("error")}}}
}
return nil
}(),
}, },
Body: funcBody, Body: funcBody,
} }
return funcDecl return funcDecl
} }
func getByIDFuncName(tblname string) string {
return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
}
// 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 {
funcName := "Get" + tbl.GoTypeName + "ByID"
recv := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}} 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)}}} 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")}}} 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")}}}
@ -228,7 +443,7 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
funcDecl := &ast.FuncDecl{ funcDecl := &ast.FuncDecl{
Recv: recv, Recv: recv,
Name: ast.NewIdent(funcName), Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
Type: &ast.FuncType{Params: arg, Results: result}, Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody, Body: funcBody,
} }
@ -258,12 +473,12 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, &ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
&ast.BinaryExpr{ &ast.BinaryExpr{
X: &ast.BinaryExpr{ X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`SELECT `"}, X: &ast.BasicLit{Kind: token.STRING, Value: "`select `"},
Op: token.ADD, Op: token.ADD,
Y: SQLFieldsConstIdent(tbl), Y: SQLFieldsConstIdent(tbl),
}, },
Op: token.ADD, Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: "` FROM " + tbl.TableName + "`"}, Y: &ast.BasicLit{Kind: token.STRING, Value: "` from " + tbl.TableName + "`"},
}, },
}, },
}, },
@ -351,8 +566,12 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl { func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
columns := make([]string, 0, len(tbl.Columns)) columns := make([]string, 0, len(tbl.Columns))
for _, col := range tbl.Columns { for _, col := range tbl.Columns {
if col.IsNullableForeignKey() {
columns = append(columns, fmt.Sprintf("ifnull(%s, 0) %s", col.Name, col.Name))
} else {
columns = append(columns, col.Name) columns = append(columns, col.Name)
} }
}
// Join with comma and space // Join with comma and space
value := "`" + strings.Join(columns, ", ") + "`" value := "`" + strings.Join(columns, ", ") + "`"

View File

@ -83,6 +83,30 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
}, },
} }
// func MakeItem() Item { return Item{} }
makeItemFunc := &ast.FuncDecl{
Name: ast.NewIdent("Make" + tbl.GoTypeName),
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),
},
},
},
},
},
}
testObj := ast.NewIdent("item") testObj := ast.NewIdent("item")
testObj2 := ast.NewIdent("item2") testObj2 := ast.NewIdent("item2")
fieldName := ast.NewIdent("Description") fieldName := ast.NewIdent("Description")
@ -209,7 +233,7 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
Args: []ast.Expr{ Args: []ast.Expr{
ast.NewIdent("t"), ast.NewIdent("t"),
ast.NewIdent("err"), ast.NewIdent("err"),
&ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("ErrNotInDB")}, ast.NewIdent("ErrNotInDB"),
}, },
}}, }},
}, },
@ -237,9 +261,126 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
}, },
} }
shouldIncludeTestFkCheck := false
testFkChecking := &ast.FuncDecl{
Name: ast.NewIdent("Test" + tbl.GoTypeName + "FkChecking"),
Type: &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"),
},
},
},
},
},
},
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(fkFieldName(col)),
},
},
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: ast.NewIdent("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", fkFieldName(col)),
},
&ast.SelectorExpr{
X: ast.NewIdent(tbl.VarName),
Sel: ast.NewIdent(fkFieldName(col)),
},
},
},
},
}...)
}
}
return stmts
}(),
},
}
testList := []ast.Decl{
// var TestDB *DB
testDBDecl,
makeItemFunc,
// func init() { TestDB = MakeDB("tmp") }
initFuncDecl,
// func MakeDB(dbName string) *DB { db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName))); return db }
makeDBHelperDecl,
testCreateUpdateDelete,
testGetAll,
}
if shouldIncludeTestFkCheck {
testList = append(testList, testFkChecking)
}
return &ast.File{ return &ast.File{
Name: ast.NewIdent(testpackageName), Name: ast.NewIdent(testpackageName),
Decls: []ast.Decl{ Decls: append([]ast.Decl{
&ast.GenDecl{ &ast.GenDecl{
Tok: token.IMPORT, Tok: token.IMPORT,
Specs: []ast.Spec{ Specs: []ast.Spec{
@ -247,7 +388,7 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
&ast.ImportSpec{ &ast.ImportSpec{
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"`},
Name: ast.NewIdent("db"), Name: ast.NewIdent("."),
}, },
&ast.ImportSpec{ &ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`}, Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
@ -261,17 +402,6 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
&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"`}},
}, },
}, },
// var TestDB *DB }, testList...),
testDBDecl,
// func init() { TestDB = MakeDB("tmp") }
initFuncDecl,
// func MakeDB(dbName string) *DB { db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName))); return db }
makeDBHelperDecl,
testCreateUpdateDelete,
testGetAll,
},
} }
} }

View File

@ -0,0 +1,21 @@
package db
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func AssertForeignKeyError[T ForeignKey](t *testing.T, err error, field string, val T) {
t.Helper()
var fkErr ForeignKeyError[T]
require.Error(t, err)
require.ErrorIs(t, err, ErrForeignKeyViolation)
// ErrorAs produces terrible error messages if it's a ForeignKeyError with a different type
// parameter (i.e., if it was a different field that failed).
require.ErrorAs(t, err, &fkErr, "expected error field: %q", field)
assert.Equal(t, field, fkErr.Field)
assert.Equal(t, val, fkErr.FkValue)
}

View File

@ -1,5 +1,7 @@
package schema package schema
import "strings"
// Column represents a single column in a table. // Column represents a single column in a table.
type Column struct { type Column struct {
TableName string `db:"table_name"` TableName string `db:"table_name"`
@ -20,6 +22,10 @@ func (c Column) IsNullableForeignKey() bool {
return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey
} }
func (c Column) IsNonCodeTableForeignKey() bool {
return c.IsForeignKey && strings.HasSuffix(c.Name, "_id")
}
// Table is a single SQLite table. // Table is a single SQLite table.
type Table struct { type Table struct {
TableName string `db:"name"` TableName string `db:"name"`