18 Commits

Author SHA1 Message Date
5cbb657666 codegen: add helpers
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 15s
2026-02-15 12:24:39 -08:00
b8a024a4b9 doc: remove completed auto-timestamp and fk check TODO items 2026-02-15 11:19:05 -08:00
4cba2af670 codegen: more AST style improvements
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-02-14 23:19:55 -08:00
04676461ff doc: add TODOs
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 26s
2026-02-14 22:56:43 -08:00
a36058fdbe codegen (refactor): clean up helpers a bit 2026-02-14 22:54:22 -08:00
f0c152cfe4 codegen: add auto-timestamp checking to generated tests 2026-02-14 22:17:59 -08:00
5973a2a4b7 codegen: implement auto-timestamps for created_at and updated_at
Some checks failed
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 3m7s
2026-02-14 21:38:09 -08:00
75b7662c34 lint: fix lll and imports
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-02-07 22:29:57 -08:00
229f41e478 schema: add tests to show that migrations can be validated
Some checks failed
CI / build-docker (push) Successful in 4s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 9s
2026-02-07 18:12:53 -08:00
2e50736e67 schema: enable parsing a schema from an existing sqlx.DB instance
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 2m30s
2026-02-07 15:09:05 -08:00
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
15 changed files with 888 additions and 262 deletions

5
.gitignore vendored
View File

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

View File

@@ -1,17 +1,31 @@
TODO: auto-timestamps
- SaveXyz should set created_at and updated_at; shouldn't touch is_deleted or deleted_at
TODO: soft-deletion
- enable soft-deletion if table has `is_deleted` and `deleted_at` fields
- if soft delete is enabled, DeleteXyz should do update (not delete) and set is_deleted and deleted_at
- ...and DeleteXyz should have pointer receiver for soft-delete
- SaveXyz shouldn't set created_at in the do-update branch
- GetXyzByID should include `ErrItemIsDeleted` if item is soft-deleted
TODO: modified-timestamps
- set updated_at and created_at in SaveXYZ
- soft delete option
TODO: generator-foreign-keys
- add auto-foreign-key checking blocks to SaveXyz
TODO: migration-structs
- 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)
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 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 "

1
go.mod
View File

@@ -3,6 +3,7 @@ module git.offline-twitter.com/offline-labs/gas-stack
go 1.22.5
require (
github.com/go-test/deep v1.1.1
github.com/jinzhu/inflection v1.0.0
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.24

2
go.sum
View File

@@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=

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 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.
# The user gets sudo of course.
#

View File

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

View File

@@ -7,6 +7,7 @@ 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

@@ -9,7 +9,8 @@ set -e
set -x
PS4='+(${BASH_SOURCE}:${LINENO}): '
cd "$(dirname "${BASH_SOURCE[0]}")/.."
proj_root=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..")
cd "$proj_root"
# Compile `gas`
gas="/tmp/gas"
@@ -29,11 +30,19 @@ EOF
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
cat >> pkg/db/schema.sql <<EOF
create table item_flavor (rowid integer primary key, name text not null) strict;
create table items (
rowid integer primary key,
description text not null default ''
description text not null default '',
flavor integer references item_flavor(rowid),
created_at integer not null,
updated_at integer not null
) strict;
EOF

View File

@@ -0,0 +1,11 @@
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,6 +13,25 @@ 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")
}
// ---------------
// 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{
@@ -37,33 +56,14 @@ func GenerateModelAST(table schema.Table) *ast.GenDecl {
Tag: &ast.BasicLit{Kind: token.STRING, Value: "`db:\"rowid\" json:\"id\"`"},
})
default:
if col.IsForeignKey && strings.HasSuffix(col.Name, "_id") {
if col.IsNonCodeTableForeignKey() {
fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(strings.TrimSuffix(col.Name, "_id")) + "ID")},
Names: []*ast.Ident{ast.NewIdent(col.GoFieldName())},
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)},
})
} else {
typeName := "string"
switch col.Type {
case "integer", "int":
if strings.HasPrefix(col.Name, "is_") || strings.HasPrefix(col.Name, "has_") {
typeName = "bool"
} else if strings.HasSuffix(col.Name, "_at") {
typeName = "Timestamp"
} else {
typeName = "int64"
}
case "text":
typeName = "string"
case "real":
typeName = "float32"
case "blob":
typeName = "[]byte"
default:
panic("Unrecognized sqlite column type: " + col.Type)
}
typeName := col.GoType()
fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(col.Name))},
Type: ast.NewIdent(typeName),
@@ -89,6 +89,9 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
insertVals := make([]string, 0, len(tbl.Columns))
updatePairs := make([]string, 0, len(tbl.Columns))
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
// Assemble data for building SQL "insert" and "update" strings
for _, col := range tbl.Columns {
if col.Name == "rowid" {
continue
@@ -99,104 +102,316 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
val = fmt.Sprintf("nullif(%s, 0)", val)
}
insertVals = append(insertVals, val)
// created_at should not be updated after creation
if col.Name == "created_at" && hasCreatedAt {
continue
}
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, ", "))
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 "))
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 := col.GoFieldName()
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: dbDB, 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
}(),
},
},
},
}
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
&ast.IfStmt{
List: func() []ast.Stmt {
ret := []ast.Stmt{}
if hasFks {
ret = append(ret, checkForeignKeyFailuresAssignment)
}
if hasUpdatedAt {
// Auto-timestamps: updated_at
ret = append(ret, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("UpdatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
// 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{
List: []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{&ast.CallExpr{
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),
// Do create
List: append(
func() []ast.Stmt {
ret1 := []ast.Stmt{}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &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")},
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: &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{
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{&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{},
}},
}},
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{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
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)},
}},
}},
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: &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{},
}},
},
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: 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")}}}}}}}},
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")}}}}}}}},
},
},
},
},
},
})
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{
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}},
Recv: dbRecv,
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)}}}},
Results: nil,
Params: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)}}}},
Results: func() *ast.FieldList {
if hasFks {
return &ast.FieldList{List: []*ast.Field{{Type: ast.NewIdent("error")}}}
}
return nil
}(),
},
Body: funcBody,
}
return funcDecl
}
func getByIDFuncName(tblname string) string {
return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
}
// 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 {
funcName := "Get" + tbl.GoTypeName + "ByID"
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")}}}
@@ -216,7 +431,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: ast.NewIdent("db.DB"), 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: dbDB, 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")}}},
@@ -227,8 +442,8 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
}
funcDecl := &ast.FuncDecl{
Recv: recv,
Name: ast.NewIdent(funcName),
Recv: dbRecv,
Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody,
}
@@ -239,9 +454,6 @@ 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)}},
}}
@@ -251,19 +463,19 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
Args: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: ast.NewIdent("db.DB"),
X: dbDB,
Sel: ast.NewIdent("Select"),
},
Args: []ast.Expr{
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
&ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`SELECT `"},
X: &ast.BasicLit{Kind: token.STRING, Value: "`select `"},
Op: token.ADD,
Y: SQLFieldsConstIdent(tbl),
},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: "` FROM " + tbl.TableName + "`"},
Y: &ast.BasicLit{Kind: token.STRING, Value: "` from " + tbl.TableName + "`"},
},
},
},
@@ -278,7 +490,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
}
return &ast.FuncDecl{
Recv: recv,
Recv: dbRecv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{
Params: &ast.FieldList{},
@@ -291,34 +503,29 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
// GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function.
// 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)}}}
arg := &ast.FieldList{List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)},
Type: ast.NewIdent(tbl.GoTypeName),
}}}
funcBody := &ast.BlockStmt{
List: []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{&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")},
},
}},
}},
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, 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: &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{}},
},
},
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"},
},
@@ -326,7 +533,7 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("fmt.Errorf"),
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")},
@@ -339,8 +546,8 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
}
funcDecl := &ast.FuncDecl{
Recv: recv,
Name: ast.NewIdent(funcName),
Recv: dbRecv,
Name: ast.NewIdent("Delete" + tbl.GoTypeName),
Type: &ast.FuncType{Params: arg, Results: nil},
Body: funcBody,
}
@@ -351,7 +558,11 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
columns := make([]string, 0, len(tbl.Columns))
for _, col := range tbl.Columns {
columns = append(columns, col.Name)
if col.IsNullableForeignKey() {
columns = append(columns, fmt.Sprintf("ifnull(%s, 0) %s", col.Name, col.Name))
} else {
columns = append(columns, col.Name)
}
}
// Join with comma and space
value := "`" + strings.Join(columns, ", ") + "`"
@@ -366,11 +577,3 @@ 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,19 +61,16 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("db")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: ast.NewIdent("Create"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Create"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("fmt.Sprintf"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"file:%s?mode=memory&cache=shared"`},
ast.NewIdent("dbName"),
},
}},
Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"file:%s?mode=memory&cache=shared"`},
ast.NewIdent("dbName"),
},
}},
}},
})},
},
// return db
&ast.ReturnStmt{
@@ -83,144 +80,187 @@ 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")
testObj2 := ast.NewIdent("item2")
fieldName := ast.NewIdent("Description")
description1 := `"an item"`
description2 := `"a big item"`
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
testFuncType := &ast.FuncType{
Params: &ast.FieldList{
List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent("t")},
Type: &ast.StarExpr{X: &ast.SelectorExpr{X: ast.NewIdent("testing"), Sel: ast.NewIdent("T")}},
}},
},
}
testCreateUpdateDelete := &ast.FuncDecl{
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
Type: &ast.FuncType{
Params: &ast.FieldList{
List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent("t")},
Type: ast.NewIdent("*testing.T"),
}},
},
},
Type: testFuncType,
Body: &ast.BlockStmt{
List: []ast.Stmt{
// item := Item{Description: "an item"}
&ast.AssignStmt{
Lhs: []ast.Expr{testObj},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName),
Elts: []ast.Expr{
&ast.KeyValueExpr{
Key: fieldName,
Value: &ast.BasicLit{Kind: token.STRING, Value: description1},
List: func() []ast.Stmt {
assertNotZero := func(obj *ast.Ident, field string) *ast.ExprStmt {
return &ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("NotZero")},
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(field)}},
}}
}
stmts := []ast.Stmt{
// item := Item{Description: "an item"}
&ast.AssignStmt{
Lhs: []ast.Expr{testObj},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName),
Elts: []ast.Expr{
&ast.KeyValueExpr{
Key: fieldName,
Value: &ast.BasicLit{Kind: token.STRING, Value: description1},
},
},
}},
},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// require.NotZero(t, item.ID)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NotZero")},
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
}
// After create: assert timestamps are set
if hasCreatedAt {
stmts = append(stmts, assertNotZero(testObj, "CreatedAt"))
}
if hasUpdatedAt {
stmts = append(stmts, assertNotZero(testObj, "UpdatedAt"))
}
stmts = append(stmts,
// item2 := Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
})},
},
// assert.Equal(t, "an item", item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
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},
},
}},
},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Save" + tbl.GoTypeName),
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item.Description = "a big item"
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
},
// require.NotZero(t, item.ID)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("require.NotZero"),
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
)
// item2 := Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
stmts = append(stmts,
// item2 = Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
})},
},
// assert.Equal(t, item.Description, item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
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},
},
}},
// TestDB.DeleteItem(item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Delete" + tbl.GoTypeName)},
Args: []ast.Expr{testObj},
}},
// _, err := TestDB.GetItemByID(item.ID)
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("TestDB"), Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
}},
},
// assert.Equal(t, "an item", item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.Equal"),
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.BasicLit{Kind: token.STRING, Value: description1},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
// item.Description = "a big item"
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Save" + tbl.GoTypeName),
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item2 = Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
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.ErrorIs(t, err, db.ErrNotInDB)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("ErrorIs")},
Args: []ast.Expr{
ast.NewIdent("t"),
ast.NewIdent("err"),
ast.NewIdent("ErrNotInDB"),
},
}},
},
)
// assert.Equal(t, item.Description, item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.Equal"),
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.SelectorExpr{X: testObj, Sel: fieldName},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
// TestDB.DeleteItem(item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Delete" + tbl.GoTypeName),
Args: []ast.Expr{testObj},
}},
// _, err := TestDB.GetItemByID(item.ID)
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
},
// assert.ErrorIs(t, err, db.ErrNotInDB)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.ErrorIs"),
Args: []ast.Expr{
ast.NewIdent("t"),
ast.NewIdent("err"),
&ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("ErrNotInDB")},
},
}},
},
return stmts
}(),
},
}
testGetAll := &ast.FuncDecl{
Name: ast.NewIdent("TestGetAll" + inflection.Plural(tbl.GoTypeName)),
Type: &ast.FuncType{Params: &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("t")}, Type: &ast.StarExpr{X: ast.NewIdent("testing.T")}},
}}, Results: nil},
Type: testFuncType,
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
@@ -237,9 +277,112 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
},
}
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: 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", col.GoFieldName()),
},
&ast.SelectorExpr{
X: ast.NewIdent(tbl.VarName),
Sel: ast.NewIdent(col.GoFieldName()),
},
},
},
},
}...)
}
}
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{
Name: ast.NewIdent(testpackageName),
Decls: []ast.Decl{
Decls: append([]ast.Decl{
&ast.GenDecl{
Tok: token.IMPORT,
Specs: []ast.Spec{
@@ -247,7 +390,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: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
Name: ast.NewIdent("db"),
Name: ast.NewIdent("."),
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
@@ -261,17 +404,6 @@ func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
},
},
// var TestDB *DB
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,
},
}, testList...),
}
}

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

@@ -0,0 +1,167 @@
package schema_test
import (
"slices"
"testing"
"github.com/go-test/deep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
var (
baseSchema = `
create table db_version (
version integer primary key
) strict, without rowid;
insert into db_version values(0);
create table t1 (
rowid integer primary key,
data1 integer not null,
data2 text not null
);
`
fullSchema = `
create table db_version (
version integer primary key
) strict, without rowid;
insert into db_version values(2);
create table t1 (
rowid integer primary key,
data1 integer not null,
data2 text not null,
data3 integer
);
create table t2 (
rowid integer primary key
);
`
)
func TestVerifyCorrectMigration(t *testing.T) {
db1 := schema.InitDB(fullSchema)
db1Schema := schema.SchemaFromDB(db1)
t.Run("migrate in 1 step", func(t *testing.T) {
migration := `
create table t2 (
rowid integer primary key
);
alter table t1 add column data3 integer;
`
db2Config := db.Init(&baseSchema, &[]string{migration})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
if diff := deep.Equal(db1Schema, db2Schema); diff != nil {
t.Error(diff)
}
})
t.Run("migrate in 2 steps", func(t *testing.T) {
migration1 := `
create table t2 (
rowid integer primary key
);
`
migration2 := `
alter table t1 add column data3 integer;
`
db2Config := db.Init(&baseSchema, &[]string{migration1, migration2})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
if diff := deep.Equal(db1Schema, db2Schema); diff != nil {
t.Error(diff)
}
})
}
func TestIncorrectMigrations(t *testing.T) {
db1 := schema.InitDB(fullSchema)
db1Schema := schema.SchemaFromDB(db1)
t.Run("missing migration", func(t *testing.T) {
db2Config := db.Init(&baseSchema, &[]string{})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
// Missing a table
assert.Len(t, db1Schema.Tables, len(db2Schema.Tables)+1)
assert.Contains(t, db1Schema.Tables, "t2")
assert.NotContains(t, db2Schema.Tables, "t2")
// Missing the new column
assert.Len(t, db1Schema.Tables["t1"].Columns, len(db2Schema.Tables["t1"].Columns)+1)
assert.True(t, slices.ContainsFunc(db1Schema.Tables["t1"].Columns, func(c schema.Column) bool { return c.Name == "data3" }), "t2")
assert.False(t, slices.ContainsFunc(db2Schema.Tables["t1"].Columns, func(c schema.Column) bool { return c.Name == "data3" }), "t2")
})
t.Run("incomplete migration", func(t *testing.T) {
db2Config := db.Init(&baseSchema, &[]string{`
create table t2 (
rowid integer primary key
);
`})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
// Has the new table
assert.Len(t, db1Schema.Tables, len(db2Schema.Tables))
assert.Contains(t, db1Schema.Tables, "t2")
assert.Contains(t, db2Schema.Tables, "t2")
// Still missing the new column
assert.Len(t, db1Schema.Tables["t1"].Columns, len(db2Schema.Tables["t1"].Columns)+1)
assert.True(t, slices.ContainsFunc(db1Schema.Tables["t1"].Columns, func(c schema.Column) bool { return c.Name == "data3" }), "t2")
assert.False(t, slices.ContainsFunc(db2Schema.Tables["t1"].Columns, func(c schema.Column) bool { return c.Name == "data3" }), "t2")
})
t.Run("incorrect migration (wrong data type)", func(t *testing.T) {
db2Config := db.Init(&baseSchema, &[]string{`
create table t2 (
rowid integer primary key
);
alter table t1 add column data3 text;
`})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
// Has the new table
assert.Len(t, db1Schema.Tables, len(db2Schema.Tables))
assert.Contains(t, db1Schema.Tables, "t2")
assert.Contains(t, db2Schema.Tables, "t2")
// Has the right column, but it's the wrong type
assert.Len(t, db1Schema.Tables["t1"].Columns, len(db2Schema.Tables["t1"].Columns))
col1 := db1Schema.Tables["t1"].Columns[slices.IndexFunc(
db1Schema.Tables["t1"].Columns,
func(c schema.Column) bool { return c.Name == "data3" },
)]
col2 := db2Schema.Tables["t1"].Columns[slices.IndexFunc(
db2Schema.Tables["t1"].Columns,
func(c schema.Column) bool { return c.Name == "data3" },
)]
assert.NotEqual(t, col1, col2)
assert.Equal(t, col1.Type, "integer") // Full schema has an integer column
assert.Equal(t, col2.Type, "text") // Migration incorrectly uses a text column
// Other than that they are equal
col2.Type = "integer"
assert.Equal(t, col1, col2)
})
}

View File

@@ -21,7 +21,6 @@ var create_views string
func InitDB(sql_schema string) *sqlx.DB {
db := sqlx.MustOpen("sqlite3", ":memory:")
db.MustExec(sql_schema)
db.MustExec(create_views)
return db
}
@@ -36,6 +35,7 @@ func SchemaFromSQLFile(filepath string) (Schema, error) {
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
func SchemaFromDB(db *sqlx.DB) Schema {
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
db.MustExec(create_views)
var tables []Table
PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))

View File

@@ -1,5 +1,11 @@
package schema
import (
"strings"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
// Column represents a single column in a table.
type Column struct {
TableName string `db:"table_name"`
@@ -20,6 +26,43 @@ func (c Column) IsNullableForeignKey() bool {
return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey
}
func (c Column) IsNonCodeTableForeignKey() bool {
return c.IsForeignKey && strings.HasSuffix(c.Name, "_id")
}
func (c Column) GoFieldName() string {
if c.Name == "rowid" {
return "ID"
}
if c.IsNonCodeTableForeignKey() {
return textutils.SnakeToCamel(strings.TrimSuffix(c.Name, "_id")) + "ID"
}
return textutils.SnakeToCamel(c.Name)
}
func (c Column) GoType() string {
if c.IsNonCodeTableForeignKey() {
return TypenameFromTablename(c.ForeignKeyTargetTable) + "ID"
}
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.
type Table struct {
TableName string `db:"name"`
@@ -40,6 +83,18 @@ type Table struct {
GoTypeName string
}
func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
for _, c := range t.Columns {
if c.Name == "created_at" && c.Type == "integer" {
hasCreatedAt = true
}
if c.Name == "updated_at" && c.Type == "integer" {
hasUpdatedAt = true
}
}
return
}
type Index struct {
Name string `db:"index_name"`
TableName string `db:"table_name"`