Compare commits
33 Commits
c09a2fe1fb
...
wispem/cod
| Author | SHA1 | Date | |
|---|---|---|---|
| 83a7087a3b | |||
| 4f3865deaa | |||
| 3f2db59b3d | |||
| 89b2fd7c3c | |||
| a1b06a3ef8 | |||
| 49d2a7748f | |||
| ae036d15f2 | |||
| b33127db6c | |||
| f542f45630 | |||
| cf989f7433 | |||
| a101c7531b | |||
| 9c31266f59 | |||
| 616304c7dd | |||
| 17fc8a68f6 | |||
| d572745613 | |||
| 9bfb31798c | |||
| eafeb658bd | |||
| dbf14e23b6 | |||
| ad1782c73d | |||
| ccd7e32cbf | |||
| ed4ade1956 | |||
| 9a11f3986c | |||
| 8c29d455ff | |||
| 0f9a57dd85 | |||
| 11fed4b9c7 | |||
| 4e0836eb2e | |||
| f82929f6e2 | |||
| 1bc7f9111f | |||
| c1150954e5 | |||
| fd90830340 | |||
| fcf266eb1d | |||
| 29787b5521 | |||
| e53546a7f5 |
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on: [push]
|
||||
on: [push, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
# These steps build the `gas` docker image.
|
||||
|
||||
@@ -60,8 +60,6 @@ linters:
|
||||
- -ST1000 # Re-enable this once we have docstrings
|
||||
- -ST1003 # I like snake_case
|
||||
- -ST1013 # HTTP status codes are shorter and more readable than names
|
||||
dot-import-whitelist:
|
||||
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
exclusions:
|
||||
generated: lax # Don't lint generated files
|
||||
paths:
|
||||
|
||||
48
README.md
Normal file
48
README.md
Normal 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
|
||||
@@ -22,6 +22,7 @@ func main() {
|
||||
root_cmd.AddCommand(sqlite_lint)
|
||||
root_cmd.AddCommand(cmd_init)
|
||||
root_cmd.AddCommand(generate_model)
|
||||
root_cmd.AddCommand(generate_codetable_type)
|
||||
if err := root_cmd.Execute(); err != nil {
|
||||
fmt.Println(RED + err.Error() + RESET)
|
||||
os.Exit(1)
|
||||
|
||||
55
cmd/subcmd_generate_codetable_type.go
Normal file
55
cmd/subcmd_generate_codetable_type.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"os"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var generate_codetable_type = &cobra.Command{
|
||||
Use: "generate_codetable <table_name>",
|
||||
Short: "Generate a code-table enum type",
|
||||
|
||||
Args: cobra.ExactArgs(1),
|
||||
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := Must(cmd.Flags().GetString("schema"))
|
||||
sql, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading path %s: %w", path, err)
|
||||
}
|
||||
db := schema.InitDB(string(sql))
|
||||
schema := schema.SchemaFromDB(db)
|
||||
table, isOk := schema.Tables[args[0]]
|
||||
if !isOk {
|
||||
return ErrNoSuchTable
|
||||
}
|
||||
|
||||
vals := table.GetCodeTableValues(db)
|
||||
decls := []ast.Decl{
|
||||
modelgenerate.GenerateCodetableType(table),
|
||||
modelgenerate.GenerateCodetableEnum(table, vals),
|
||||
modelgenerate.GenerateCodetableStringerFunc(table, vals),
|
||||
}
|
||||
|
||||
file := &ast.File{
|
||||
Name: ast.NewIdent("db"), // TODO: parameterize
|
||||
Decls: decls,
|
||||
}
|
||||
|
||||
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DUPE: generate-flags
|
||||
func init() {
|
||||
generate_codetable_type.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||
generate_codetable_type.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||
generate_codetable_type.Flags().Bool("test", false, "Generate test file instead of regular file")
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -23,22 +24,22 @@ var generate_model = &cobra.Command{
|
||||
Args: cobra.ExactArgs(1),
|
||||
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := Must(cmd.Flags().GetString("schema"))
|
||||
modname := Must(cmd.Flags().GetString("modname"))
|
||||
path := must.Get(cmd.Flags().GetString("schema"))
|
||||
modname := must.Get(cmd.Flags().GetString("modname"))
|
||||
sql, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading path %s: %w", path, err)
|
||||
}
|
||||
db := schema.InitDB(string(sql))
|
||||
schema := schema.SchemaFromDB(db)
|
||||
table, isOk := schema.Tables[args[0]]
|
||||
sch := schema.SchemaFromDB(db)
|
||||
table, isOk := sch.Tables[args[0]]
|
||||
if !isOk {
|
||||
return ErrNoSuchTable
|
||||
}
|
||||
|
||||
if Must(cmd.Flags().GetBool("test")) {
|
||||
file2 := modelgenerate.GenerateModelTestAST(table, schema, modname)
|
||||
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file2))
|
||||
if must.Get(cmd.Flags().GetBool("test")) {
|
||||
file2 := modelgenerate.GenerateModelTestAST(table, sch, modname)
|
||||
must.Do(modelgenerate.FprintWithComments(os.Stdout, file2))
|
||||
} else {
|
||||
decls := []ast.Decl{
|
||||
&ast.GenDecl{
|
||||
@@ -46,16 +47,14 @@ var generate_model = &cobra.Command{
|
||||
Specs: []ast.Spec{
|
||||
&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: `"fmt"`}},
|
||||
&ast.ImportSpec{
|
||||
Name: ast.NewIdent("."),
|
||||
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
|
||||
},
|
||||
&ast.ImportSpec{
|
||||
Name: ast.NewIdent("."),
|
||||
Path: &ast.BasicLit{
|
||||
Kind: token.STRING,
|
||||
Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`,
|
||||
Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -69,15 +68,33 @@ var generate_model = &cobra.Command{
|
||||
modelgenerate.GenerateSQLFieldsConst(table),
|
||||
modelgenerate.GenerateSaveItemFunc(table),
|
||||
modelgenerate.GenerateDeleteItemFunc(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 sch.Indexes {
|
||||
if index.TableName != table.TableName {
|
||||
// Skip indexes on other tables
|
||||
continue
|
||||
}
|
||||
if index.IsUnique && len(index.Columns) == 1 {
|
||||
decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
|
||||
if slices.Contains(index.Columns, "") {
|
||||
// Skip expression indexes; there's no way to resolve an expression to a real column
|
||||
continue
|
||||
}
|
||||
cols := make([]schema.Column, len(index.Columns))
|
||||
for i, colName := range index.Columns {
|
||||
cols[i] = table.GetColumnByName(colName)
|
||||
}
|
||||
if index.IsUnique {
|
||||
decls = append(decls, modelgenerate.GenerateGetItemBy(table, cols))
|
||||
} else {
|
||||
decls = append(decls, modelgenerate.GenerateGetItemsBy(table, cols))
|
||||
}
|
||||
}
|
||||
decls = append(decls, modelgenerate.GenerateGetAllItemsFunc(table))
|
||||
@@ -87,13 +104,14 @@ var generate_model = &cobra.Command{
|
||||
Decls: decls,
|
||||
}
|
||||
|
||||
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
must.Do(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DUPE: generate-flags
|
||||
func init() {
|
||||
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||
generate_model.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen"
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
)
|
||||
|
||||
var cmd_init = &cobra.Command{
|
||||
@@ -23,11 +23,11 @@ var cmd_init = &cobra.Command{
|
||||
var target string
|
||||
if len(args) != 0 {
|
||||
target = args[0]
|
||||
PanicIf(os.MkdirAll(target, 0o755))
|
||||
PanicIf(os.Chdir(target))
|
||||
must.Do(os.MkdirAll(target, 0o755))
|
||||
must.Do(os.Chdir(target))
|
||||
} else {
|
||||
// Default to current directory (".")
|
||||
target = Must(os.Getwd())
|
||||
target = must.Get(os.Getwd())
|
||||
}
|
||||
|
||||
// Get all the config values
|
||||
@@ -41,9 +41,9 @@ var cmd_init = &cobra.Command{
|
||||
}
|
||||
}
|
||||
pkg_opts := codegen.PkgOpts{
|
||||
ModuleName: Must(cmd.Flags().GetString("module")),
|
||||
DBFilename: Must(cmd.Flags().GetString("db")),
|
||||
BinaryName: Must(cmd.Flags().GetString("binary")),
|
||||
ModuleName: must.Get(cmd.Flags().GetString("module")),
|
||||
DBFilename: must.Get(cmd.Flags().GetString("db")),
|
||||
BinaryName: must.Get(cmd.Flags().GetString("binary")),
|
||||
}
|
||||
if pkg_opts.ModuleName == "" {
|
||||
pkg_opts.ModuleName = filepath.Base(target)
|
||||
|
||||
@@ -41,15 +41,24 @@ create table items (
|
||||
rowid integer primary key,
|
||||
description text not null default '',
|
||||
flavor integer references item_flavor(rowid),
|
||||
data blob not null,
|
||||
thing text not null unique,
|
||||
created_at integer not null,
|
||||
updated_at integer not null
|
||||
) 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
|
||||
|
||||
# Generate an item model and test file
|
||||
$gas generate items > pkg/db/item.go
|
||||
$gas generate items --test > pkg/db/item_test.go
|
||||
$gas generate item_to_item > pkg/db/item_to_item.go
|
||||
go mod tidy
|
||||
|
||||
# Run the tests
|
||||
|
||||
3
ops/test.sh
Normal file
3
ops/test.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
go test -tags fts5 ./...
|
||||
@@ -2,6 +2,7 @@ package modelgenerate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/printer"
|
||||
@@ -26,10 +27,18 @@ const (
|
||||
// we don't have to do that.
|
||||
var TrailingComments = map[ast.Node]string{}
|
||||
|
||||
// mustCall wraps a call expression in Must(...), producing AST for Must(inner).
|
||||
// mustCall wraps a call expression in must.Get(...), producing AST for must.Get(inner).
|
||||
func mustCall(inner ast.Expr) *ast.CallExpr {
|
||||
return &ast.CallExpr{
|
||||
Fun: ast.NewIdent("Must"),
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Get")},
|
||||
Args: []ast.Expr{inner},
|
||||
}
|
||||
}
|
||||
|
||||
// doCall wraps a call expression in must.Do(...), producing AST for must.Do(inner).
|
||||
func doCall(inner ast.Expr) *ast.CallExpr {
|
||||
return &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Do")},
|
||||
Args: []ast.Expr{inner},
|
||||
}
|
||||
}
|
||||
@@ -65,17 +74,22 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
var buf bytes.Buffer
|
||||
fset := token.NewFileSet()
|
||||
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)
|
||||
fset = token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("re-parsing pretty-print: %w\n\n%s", err, buf.String())
|
||||
}
|
||||
|
||||
// Convert the tree-of-nodes into a slice-of-nodes
|
||||
// Parallel walk: apply TrailingComments from the side map.
|
||||
// Both trees have identical structure (the reparse is just a positioned copy),
|
||||
// so ast.Inspect visits nodes in the same order. We skip comment nodes to
|
||||
// avoid mismatches from Doc fields.
|
||||
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 {
|
||||
@@ -90,16 +104,18 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
return nodes
|
||||
}
|
||||
|
||||
// Parallel walk: apply TrailingComments from the side map.
|
||||
// Both trees have identical structure (the reparse is just a positioned copy),
|
||||
// so ast.Inspect visits nodes in the same order. We skip comment nodes to
|
||||
// avoid mismatches from Doc fields.
|
||||
if len(TrailingComments) > 0 {
|
||||
origNodes := collectNodes(file)
|
||||
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 {
|
||||
text, ok := TrailingComments[orig]
|
||||
if !ok {
|
||||
text, isOk := TrailingComments[orig]
|
||||
if !isOk {
|
||||
continue
|
||||
}
|
||||
reparsed := reparsedNodes[i]
|
||||
@@ -110,40 +126,43 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
}
|
||||
|
||||
extractCommentMarker := func(stmt ast.Stmt) (string, bool) {
|
||||
expr, ok := stmt.(*ast.ExprStmt)
|
||||
if !ok {
|
||||
expr, isOk := stmt.(*ast.ExprStmt)
|
||||
if !isOk {
|
||||
return "", false
|
||||
}
|
||||
call, ok := expr.X.(*ast.CallExpr)
|
||||
if !ok {
|
||||
call, isOk := expr.X.(*ast.CallExpr)
|
||||
if !isOk {
|
||||
return "", false
|
||||
}
|
||||
ident, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || ident.Name != commentMarker {
|
||||
ident, isOk := call.Fun.(*ast.Ident)
|
||||
if !isOk || ident.Name != commentMarker {
|
||||
return "", false
|
||||
}
|
||||
lit, isOk := call.Args[0].(*ast.BasicLit)
|
||||
if !isOk {
|
||||
return "", false
|
||||
}
|
||||
lit := call.Args[0].(*ast.BasicLit)
|
||||
return lit.Value[1 : len(lit.Value)-1], true
|
||||
}
|
||||
|
||||
isBlankLineMarker := func(stmt ast.Stmt) bool {
|
||||
expr, ok := stmt.(*ast.ExprStmt)
|
||||
if !ok {
|
||||
expr, isOk := stmt.(*ast.ExprStmt)
|
||||
if !isOk {
|
||||
return false
|
||||
}
|
||||
call, ok := expr.X.(*ast.CallExpr)
|
||||
if !ok {
|
||||
call, isOk := expr.X.(*ast.CallExpr)
|
||||
if !isOk {
|
||||
return false
|
||||
}
|
||||
ident, ok := call.Fun.(*ast.Ident)
|
||||
return ok && ident.Name == blankLineMarker
|
||||
ident, isOk := call.Fun.(*ast.Ident)
|
||||
return isOk && ident.Name == blankLineMarker
|
||||
}
|
||||
|
||||
// Convert comment and blank-line markers
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
// We only care about Block nodes
|
||||
block, ok := n.(*ast.BlockStmt)
|
||||
if !ok {
|
||||
block, isOk := n.(*ast.BlockStmt)
|
||||
if !isOk {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -151,7 +170,7 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
// in the block statement's body with actual ones
|
||||
filtered := block.List[:0]
|
||||
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
|
||||
parsed.Comments = append(parsed.Comments, &ast.CommentGroup{
|
||||
List: []*ast.Comment{{Slash: stmt.Pos(), Text: "// " + text}},
|
||||
@@ -176,5 +195,9 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
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
|
||||
}
|
||||
|
||||
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package modelgenerate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
func GenerateCodetableType(table schema.Table) *ast.GenDecl {
|
||||
return &ast.GenDecl{
|
||||
Tok: token.TYPE,
|
||||
Specs: []ast.Spec{&ast.TypeSpec{Name: &ast.Ident{Name: table.GoTypeName}, Type: &ast.Ident{Name: "int"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateCodetableEnum(table schema.Table, vals []string) *ast.GenDecl {
|
||||
getConstName := func(s string) string {
|
||||
return table.GoTypeName + textutils.KebabToPascal(s)
|
||||
}
|
||||
|
||||
constSpecs := []ast.Spec{}
|
||||
for i, val := range vals {
|
||||
spec := &ast.ValueSpec{
|
||||
Names: []*ast.Ident{{Name: getConstName(val)}},
|
||||
}
|
||||
// Only the first one needs `iota`
|
||||
if i == 0 {
|
||||
spec.Type = &ast.Ident{Name: table.GoTypeName}
|
||||
spec.Values = []ast.Expr{&ast.BinaryExpr{
|
||||
X: &ast.Ident{Name: "iota"},
|
||||
Op: token.ADD,
|
||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||
}}
|
||||
}
|
||||
constSpecs = append(constSpecs, spec)
|
||||
}
|
||||
|
||||
return &ast.GenDecl{Tok: token.CONST, Specs: constSpecs}
|
||||
}
|
||||
|
||||
// GenerateCodetableStringerFunc implements the `Stringer` interface by defining a `String() string` function.
|
||||
func GenerateCodetableStringerFunc(table schema.Table, vals []string) *ast.FuncDecl {
|
||||
objIdent := ast.NewIdent(table.VarName)
|
||||
return &ast.FuncDecl{
|
||||
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{objIdent}, Type: &ast.Ident{Name: table.GoTypeName}}}},
|
||||
Name: &ast.Ident{Name: "String"},
|
||||
Type: &ast.FuncType{Params: &ast.FieldList{}, Results: &ast.FieldList{List: []*ast.Field{{Type: &ast.Ident{Name: "string"}}}}},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
// names := []string{ ... }
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.Ident{Name: "names"}},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{
|
||||
&ast.CompositeLit{
|
||||
Type: &ast.ArrayType{Elt: &ast.Ident{Name: "string"}},
|
||||
Elts: func() []ast.Expr {
|
||||
ret := []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `""`},
|
||||
}
|
||||
for _, val := range vals {
|
||||
ret = append(ret, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", val)})
|
||||
}
|
||||
return ret
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// if int(c) < 1 || int(c) >= len(names) { return "invalid" }
|
||||
&ast.IfStmt{
|
||||
Cond: &ast.BinaryExpr{
|
||||
X: &ast.BinaryExpr{
|
||||
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||
Op: token.LSS,
|
||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||
},
|
||||
Op: token.LOR,
|
||||
Y: &ast.BinaryExpr{
|
||||
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||
Op: token.GEQ,
|
||||
Y: &ast.CallExpr{Fun: &ast.Ident{Name: "len"}, Args: []ast.Expr{&ast.Ident{Name: "names"}}},
|
||||
},
|
||||
},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
&ast.ReturnStmt{Results: []ast.Expr{
|
||||
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")}, Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `"<%d=invalid>"`},
|
||||
objIdent,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
// return names[c]
|
||||
&ast.ReturnStmt{
|
||||
Results: []ast.Expr{
|
||||
&ast.IndexExpr{
|
||||
X: &ast.Ident{Name: "names"},
|
||||
Index: objIdent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jinzhu/inflection"
|
||||
@@ -20,11 +21,81 @@ import (
|
||||
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")
|
||||
return ast.NewIdent(strings.ToLower(tbl.GoTypeName[:1]) + tbl.GoTypeName[1:] + "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)
|
||||
}
|
||||
}
|
||||
|
||||
// MustBeRowsAffected produces an AST for a `must.Be(...)` call asserting that exactly one row
|
||||
// was affected by the preceding statement, e.g.:
|
||||
//
|
||||
// must.Be(must.Get(result.RowsAffected()) == 1, "%w: Food ID=%d", ErrNotInDB, f.ID)
|
||||
//
|
||||
// For "without rowid" tables, the message includes the table's primary key column(s) instead of ID.
|
||||
func MustBeRowsAffected(tbl schema.Table) *ast.ExprStmt {
|
||||
pkParts := []string{}
|
||||
pkArgs := []ast.Expr{}
|
||||
if tbl.IsWithoutRowid {
|
||||
for _, col := range tbl.PrimaryKeyColumns() {
|
||||
verb := "%v"
|
||||
if col.Type == "integer" || col.Type == "int" || col.IsNonCodeTableForeignKey() {
|
||||
verb = "%d"
|
||||
}
|
||||
pkParts = append(pkParts, fmt.Sprintf("%s=%s", col.Name, verb))
|
||||
pkArgs = append(pkArgs, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(col.GoFieldName())})
|
||||
}
|
||||
} else {
|
||||
pkParts = append(pkParts, "ID=%d")
|
||||
pkArgs = append(pkArgs, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")})
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%%w: %s %s", tbl.GoTypeName, strings.Join(pkParts, ", "))
|
||||
|
||||
args := append([]ast.Expr{
|
||||
&ast.BinaryExpr{
|
||||
X: mustCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
|
||||
Args: []ast.Expr{},
|
||||
}),
|
||||
Op: token.EQL,
|
||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||
},
|
||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", msg)},
|
||||
ast.NewIdent("ErrNotInDB"),
|
||||
}, pkArgs...)
|
||||
|
||||
return &ast.ExprStmt{X: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Be")},
|
||||
Args: args,
|
||||
}}
|
||||
}
|
||||
|
||||
// ---------------
|
||||
@@ -63,10 +134,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)},
|
||||
})
|
||||
} else {
|
||||
typeName := col.GoTypeName()
|
||||
fields = append(fields, &ast.Field{
|
||||
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)},
|
||||
})
|
||||
}
|
||||
@@ -120,9 +190,21 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
|
||||
structFieldName := col.GoFieldName()
|
||||
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
|
||||
|
||||
ret = append(ret, func() ast.Stmt {
|
||||
// Wrap nullable FKs in "if a.val != 0 { ... }"
|
||||
wrap := func(input ast.Stmt) ast.Stmt {
|
||||
if col.IsNullableForeignKey() {
|
||||
return &ast.IfStmt{
|
||||
Cond: &ast.BinaryExpr{X: structField, Op: token.NEQ, Y: &ast.BasicLit{Kind: token.INT, Value: "0"}},
|
||||
Body: &ast.BlockStmt{List: []ast.Stmt{input}},
|
||||
}
|
||||
} else {
|
||||
return input
|
||||
}
|
||||
}
|
||||
if col.IsNonCodeTableForeignKey() {
|
||||
// Real foreign key; look up referent by ID to see if it exists
|
||||
ret = append(ret, &ast.IfStmt{
|
||||
return wrap(&ast.IfStmt{
|
||||
Init: &ast.AssignStmt{
|
||||
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
||||
Tok: token.DEFINE,
|
||||
@@ -156,10 +238,10 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
|
||||
})
|
||||
} else {
|
||||
// Code table value. Query the table to see if it exists
|
||||
ret = append(ret, &ast.IfStmt{
|
||||
return wrap(&ast.IfStmt{
|
||||
Init: &ast.AssignStmt{
|
||||
Lhs: []ast.Expr{ast.NewIdent("err")},
|
||||
Tok: token.ASSIGN,
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{
|
||||
&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
|
||||
@@ -183,7 +265,7 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
|
||||
Fun: ast.NewIdent("NewForeignKeyError"),
|
||||
Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
|
||||
ast.NewIdent(fmt.Sprintf("%q", col.ForeignKeyTargetTable)),
|
||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
|
||||
structField,
|
||||
},
|
||||
},
|
||||
@@ -193,6 +275,7 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
|
||||
},
|
||||
})
|
||||
}
|
||||
}())
|
||||
}
|
||||
// final return nil
|
||||
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
|
||||
@@ -229,10 +312,29 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
if col.Name == "created_at" && hasCreatedAt {
|
||||
continue
|
||||
}
|
||||
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 "))
|
||||
}
|
||||
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 "))
|
||||
}
|
||||
|
||||
checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl)
|
||||
|
||||
@@ -250,43 +352,25 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
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{
|
||||
// Do create
|
||||
List: append(
|
||||
func() []ast.Stmt {
|
||||
ret1 := []ast.Stmt{Comment("Do create")}
|
||||
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{
|
||||
|
||||
namedExecStmt := func(stmt string) []ast.Stmt {
|
||||
queryStmt := &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
|
||||
Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"},
|
||||
&ast.BasicLit{Kind: token.STRING, Value: "`" + stmt + "`"},
|
||||
ast.NewIdent(tbl.VarName),
|
||||
},
|
||||
}
|
||||
if !hasFks {
|
||||
// No foreign key checking needed; just use `Must` for brevity
|
||||
return append(ret1, &ast.AssignStmt{
|
||||
// No foreign key checking needed; just use `must.Get` for brevity
|
||||
return []ast.Stmt{&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{ast.NewIdent("result")},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{mustCall(namedExecStmt)},
|
||||
})
|
||||
Rhs: []ast.Expr{mustCall(queryStmt)},
|
||||
}}
|
||||
}
|
||||
|
||||
return append(ret1,
|
||||
// There's foreign keys
|
||||
return []ast.Stmt{
|
||||
// result, err := db.DB.NamedExec(`...`, u)
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{
|
||||
@@ -294,9 +378,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
ast.NewIdent("err"),
|
||||
},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{namedExecStmt},
|
||||
Rhs: []ast.Expr{queryStmt},
|
||||
},
|
||||
|
||||
// if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) }
|
||||
&ast.IfStmt{
|
||||
Init: &ast.AssignStmt{
|
||||
@@ -341,7 +424,63 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
}
|
||||
}(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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")}},
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
ret = append(ret, namedExecStmt(upsertStmt)...)
|
||||
ret = append(ret, MustBeRowsAffected(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")}},
|
||||
@@ -358,31 +497,16 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
},
|
||||
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")}}}}}}}},
|
||||
},
|
||||
},
|
||||
List: append(
|
||||
[]ast.Stmt{Comment("Do update")},
|
||||
append(
|
||||
namedExecStmt(updateStmt),
|
||||
MustBeRowsAffected(tbl),
|
||||
)...,
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
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")}})
|
||||
@@ -416,6 +540,97 @@ func getByIDFuncName(tblname string) string {
|
||||
return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
|
||||
}
|
||||
|
||||
// paramNamesFor picks parameter identifiers for a `Get...By()` function's columns.
|
||||
// A single column always uses the short GoVarName() form (e.g. "name"). For multiple
|
||||
// columns, GoVarName() is preferred for readability, but if two or more of the given
|
||||
// columns would produce the same short name (e.g. two foreign keys that both abbreviate
|
||||
// to "uID"), the longer, unambiguous LongGoVarName() form is used for all of them instead,
|
||||
// since a collision there would produce invalid (duplicate-parameter) Go code.
|
||||
func paramNamesFor(cols []schema.Column) []string {
|
||||
names := make([]string, len(cols))
|
||||
if len(cols) == 1 {
|
||||
names[0] = cols[0].GoVarName()
|
||||
return names
|
||||
}
|
||||
|
||||
hasConflict := false
|
||||
for i, c := range cols {
|
||||
names[i] = c.GoVarName()
|
||||
if slices.Contains(names[:i], names[i]) {
|
||||
hasConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasConflict {
|
||||
return names
|
||||
}
|
||||
for i, c := range cols {
|
||||
names[i] = c.LongGoVarName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// GenerateGetItemBy produces an AST for a `GetXyzByCol1AndCol2...()` function that returns
|
||||
// the single item matching an exact-match lookup over the given columns (or ErrNotInDB).
|
||||
// Used for unique indexes (single- or multi-column) and for "without rowid" tables' primary keys.
|
||||
func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
||||
paramNames := paramNamesFor(cols)
|
||||
|
||||
colNames := []string{}
|
||||
funcNameSuffix := []string{}
|
||||
funcParams := &ast.FieldList{List: []*ast.Field{}}
|
||||
sqlParams := []ast.Expr{}
|
||||
for i, col := range cols {
|
||||
funcParam := ast.NewIdent(paramNames[i])
|
||||
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.
|
||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
|
||||
func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
@@ -448,19 +663,41 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
},
|
||||
}
|
||||
|
||||
funcDecl := &ast.FuncDecl{
|
||||
return &ast.FuncDecl{
|
||||
Recv: dbRecv,
|
||||
Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
|
||||
Type: &ast.FuncType{Params: arg, Results: result},
|
||||
Body: funcBody,
|
||||
}
|
||||
return funcDecl
|
||||
}
|
||||
|
||||
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function.
|
||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
|
||||
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByCol()` function, for a unique
|
||||
// index on a single column.
|
||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByName()" function.
|
||||
func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
|
||||
// Use the xyzSQLFields constant in the select query
|
||||
return GenerateGetItemBy(tbl, []schema.Column{col})
|
||||
}
|
||||
|
||||
// GenerateGetItemsBy produces an AST for a `GetXyzsByCol1AndCol2...()` function that returns
|
||||
// all items matching an exact-match lookup over the given columns. Used for non-unique
|
||||
// indexes (single- or multi-column).
|
||||
// E.g., a table with `table.TableName = "foods"` and a non-unique index on "category" will
|
||||
// produce a "GetFoodsByCategory(category string) []Food" function.
|
||||
func GenerateGetItemsBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
||||
paramNames := paramNamesFor(cols)
|
||||
|
||||
colNames := []string{}
|
||||
funcNameSuffix := []string{}
|
||||
funcParams := &ast.FieldList{List: []*ast.Field{}}
|
||||
sqlParams := []ast.Expr{}
|
||||
for i, col := range cols {
|
||||
funcParam := ast.NewIdent(paramNames[i])
|
||||
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 `"},
|
||||
@@ -468,40 +705,40 @@ func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.Func
|
||||
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, col.Name)},
|
||||
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 "))},
|
||||
}
|
||||
|
||||
param := ast.NewIdent(col.GoVarName())
|
||||
selectCall := doCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Select")},
|
||||
Args: append([]ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr}, sqlParams...),
|
||||
})
|
||||
|
||||
return &ast.FuncDecl{
|
||||
Recv: dbRecv,
|
||||
Name: ast.NewIdent("Get" + schema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName()),
|
||||
Name: ast.NewIdent("Get" + inflection.Plural(schema.TypenameFromTablename(tbl.TableName)) + "By" + strings.Join(funcNameSuffix, "And")),
|
||||
Type: &ast.FuncType{
|
||||
Params: &ast.FieldList{List: []*ast.Field{
|
||||
{Names: []*ast.Ident{param}, Type: ast.NewIdent(col.GoTypeName())},
|
||||
}},
|
||||
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")},
|
||||
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
|
||||
}},
|
||||
},
|
||||
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: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr, param}}},
|
||||
},
|
||||
&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.ExprStmt{X: selectCall},
|
||||
&ast.ReturnStmt{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateGetItemsByColFunc produces an AST for the `GetXyzsByCol()` function, for a non-unique
|
||||
// index on a single column.
|
||||
// E.g., a table with `table.TableName = "foods"` and a non-unique index on "category" will
|
||||
// produce a "GetFoodsByCategory(category string) []Food" function.
|
||||
func GenerateGetItemsByColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
|
||||
return GenerateGetItemsBy(tbl, []schema.Column{col})
|
||||
}
|
||||
|
||||
// GenerateGetAllItemsFunc produces an AST for the `GetAllXyzs()` function.
|
||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
|
||||
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
@@ -510,10 +747,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
|
||||
}}
|
||||
|
||||
selectCall := &ast.CallExpr{
|
||||
Fun: ast.NewIdent("PanicIf"),
|
||||
Args: []ast.Expr{
|
||||
&ast.CallExpr{
|
||||
selectCall := doCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{
|
||||
X: dbDB,
|
||||
Sel: ast.NewIdent("Select"),
|
||||
@@ -530,9 +764,7 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
Y: &ast.BasicLit{Kind: token.STRING, Value: "` from " + tbl.TableName + "`"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
funcBody := &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
@@ -555,10 +787,12 @@ 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 {
|
||||
arg := &ast.FieldList{List: []*ast.Field{{
|
||||
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)},
|
||||
Type: ast.NewIdent(tbl.GoTypeName),
|
||||
}}}
|
||||
colNames := []string{}
|
||||
for _, c := range tbl.PrimaryKeyColumns() {
|
||||
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{
|
||||
List: []ast.Stmt{
|
||||
@@ -566,41 +800,24 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
Lhs: []ast.Expr{ast.NewIdent("result")},
|
||||
Tok: token.DEFINE,
|
||||
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{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"},
|
||||
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
|
||||
&ast.BasicLit{Kind: token.STRING, Value: sqlStr},
|
||||
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("\"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")},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
MustBeRowsAffected(tbl),
|
||||
},
|
||||
}
|
||||
|
||||
funcDecl := &ast.FuncDecl{
|
||||
Recv: dbRecv,
|
||||
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,
|
||||
}
|
||||
return funcDecl
|
||||
|
||||
@@ -4,20 +4,102 @@ import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jinzhu/inflection"
|
||||
|
||||
pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
// SampleValue returns a deterministic value of the appropriate type for the given column.
|
||||
func SampleValue(c pkgschema.Column, offset int) ast.Expr {
|
||||
switch c.Type {
|
||||
case "integer", "int":
|
||||
if strings.HasPrefix(c.Name, "is_") || strings.HasPrefix(c.Name, "has_") {
|
||||
// Boolean case
|
||||
if offset%2 == 0 {
|
||||
return ast.NewIdent("false")
|
||||
}
|
||||
return ast.NewIdent("true")
|
||||
} else if strings.HasSuffix(c.Name, "_at") {
|
||||
// Timestamp case
|
||||
return &ast.CallExpr{
|
||||
Fun: ast.NewIdent("TimestampFromUnix"),
|
||||
Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10000+offset)},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Regular integer case
|
||||
return &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10+offset)}
|
||||
}
|
||||
case "text":
|
||||
if offset == 0 {
|
||||
return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", "asdf")}
|
||||
}
|
||||
return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", fmt.Sprintf("asdf%d", offset))}
|
||||
case "real":
|
||||
return &ast.BasicLit{Kind: token.FLOAT, Value: fmt.Sprintf("%.2f", 1.23+float64(offset))}
|
||||
case "blob":
|
||||
return &ast.CompositeLit{
|
||||
Type: &ast.ArrayType{
|
||||
Elt: ast.NewIdent("byte"),
|
||||
},
|
||||
Elts: func() []ast.Expr {
|
||||
ret := []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.INT, Value: "72"}, // 'H'
|
||||
&ast.BasicLit{Kind: token.INT, Value: "105"}, // 'i'
|
||||
&ast.BasicLit{Kind: token.INT, Value: "33"}, // '!'
|
||||
}
|
||||
for range offset {
|
||||
ret = append(ret,
|
||||
&ast.BasicLit{Kind: token.INT, Value: "33"}, // '!'
|
||||
)
|
||||
}
|
||||
return ret
|
||||
}(),
|
||||
}
|
||||
default:
|
||||
panic("Unrecognized sqlite column type: " + c.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTestFields returns the columns for which the test generator should assign its own
|
||||
// sample values, in the MakeXyz() factory and in the create/update test: the same columns
|
||||
// that GenerateSaveItemFunc's "update" branch writes to, minus foreign keys (which can't be
|
||||
// given plausible values here) and the auto-managed "created_at"/"updated_at" columns.
|
||||
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
for _, c := range tbl.Columns {
|
||||
if c.Name == "rowid" || c.IsPrimaryKey || c.IsForeignKey {
|
||||
continue
|
||||
}
|
||||
if c.Name == "created_at" && hasCreatedAt {
|
||||
continue
|
||||
}
|
||||
if c.Name == "updated_at" && hasUpdatedAt {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GenerateModelTestAST produces an AST for a starter test file for a given model.
|
||||
func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodName string) *ast.File {
|
||||
packageName := "db"
|
||||
testpackageName := packageName + "_test"
|
||||
|
||||
makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName)
|
||||
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
updateTestFields := UpdateTestFields(tbl)
|
||||
|
||||
// func MakeItem() Item { return Item{} }
|
||||
makeItemFunc := &ast.FuncDecl{
|
||||
Name: ast.NewIdent("Make" + tbl.GoTypeName),
|
||||
Name: makeHelperName,
|
||||
Type: &ast.FuncType{
|
||||
Params: &ast.FieldList{},
|
||||
Results: &ast.FieldList{
|
||||
@@ -32,6 +114,15 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
Results: []ast.Expr{
|
||||
&ast.CompositeLit{
|
||||
Type: ast.NewIdent(tbl.GoTypeName),
|
||||
Elts: func() (ret []ast.Expr) {
|
||||
for _, c := range updateTestFields {
|
||||
ret = append(ret, &ast.KeyValueExpr{
|
||||
Key: ast.NewIdent(c.GoFieldName()),
|
||||
Value: SampleValue(c, 0),
|
||||
})
|
||||
}
|
||||
return
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,14 +130,74 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
},
|
||||
}
|
||||
|
||||
testObj := ast.NewIdent("item")
|
||||
testObj2 := ast.NewIdent("item2")
|
||||
fieldName := ast.NewIdent("Description")
|
||||
description1 := `"an item"`
|
||||
description2 := `"a big item"`
|
||||
testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
|
||||
testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
|
||||
testDB := ast.NewIdent("TestDB")
|
||||
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
// getItemByPKCall builds a call to this table's primary-key getter (e.g. `TestDB.GetItemByID(item.ID)`,
|
||||
// or `TestDB.GetItemByColAAndColB(item.ColA, item.ColB)` for "without rowid" tables with a
|
||||
// compound primary key), matching whatever GenerateGetItemByIDFunc/GenerateGetItemBy generated.
|
||||
getItemByPKCall := func(obj *ast.Ident) *ast.CallExpr {
|
||||
if !tbl.IsWithoutRowid {
|
||||
// Normal rowid table: use GetXyzByID
|
||||
return &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: obj, Sel: ast.NewIdent("ID")}},
|
||||
}
|
||||
} else {
|
||||
// "Without rowid" table: use the primary key "GetItemByBlahBlah" query func
|
||||
funcNameSuffix := []string{}
|
||||
args := []ast.Expr{}
|
||||
for _, c := range tbl.PrimaryKeyColumns() {
|
||||
funcNameSuffix = append(funcNameSuffix, c.GoFieldName())
|
||||
args = append(args, &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(c.GoFieldName())})
|
||||
}
|
||||
return &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "By" + strings.Join(funcNameSuffix, "And"))},
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
makeDeepEqual := func(obj1 *ast.Ident, obj2 *ast.Ident) *ast.IfStmt {
|
||||
return &ast.IfStmt{
|
||||
Init: &ast.AssignStmt{
|
||||
Lhs: []ast.Expr{
|
||||
&ast.Ident{Name: "diff"},
|
||||
},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{
|
||||
&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{
|
||||
X: &ast.Ident{Name: "deep"},
|
||||
Sel: &ast.Ident{Name: "Equal"},
|
||||
},
|
||||
Args: []ast.Expr{obj1, obj2},
|
||||
},
|
||||
},
|
||||
},
|
||||
Cond: &ast.BinaryExpr{
|
||||
X: &ast.Ident{Name: "diff"},
|
||||
Op: token.NEQ,
|
||||
Y: &ast.Ident{Name: "nil"},
|
||||
},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
&ast.ExprStmt{
|
||||
X: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{
|
||||
X: &ast.Ident{Name: "t"},
|
||||
Sel: &ast.Ident{Name: "Error"},
|
||||
},
|
||||
Args: []ast.Expr{
|
||||
&ast.Ident{Name: "diff"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
testFuncType := &ast.FuncType{
|
||||
Params: &ast.FieldList{
|
||||
@@ -57,6 +208,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{
|
||||
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
|
||||
Type: testFuncType,
|
||||
@@ -72,32 +320,51 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
stmts := []ast.Stmt{
|
||||
Comment("Create"),
|
||||
|
||||
// item := Item{Description: "an item"}
|
||||
// item := MakeItem()
|
||||
&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},
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}},
|
||||
},
|
||||
// item.Description = <sample value, offset 1>
|
||||
// ...one assignment per updatable field
|
||||
}
|
||||
for _, c := range updateTestFields {
|
||||
stmts = append(stmts, &ast.AssignStmt{
|
||||
Lhs: []ast.Expr{
|
||||
&ast.SelectorExpr{
|
||||
X: testObj,
|
||||
Sel: ast.NewIdent(c.GoFieldName()),
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
|
||||
// TestDB.SaveItem(&item)
|
||||
&ast.ExprStmt{X: &ast.CallExpr{
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{SampleValue(c, 1)},
|
||||
})
|
||||
}
|
||||
stmts = append(stmts,
|
||||
// TestDB.SaveItem(&item), possibly with error check
|
||||
&ast.ExprStmt{X: func() *ast.CallExpr {
|
||||
mainExpr := &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
|
||||
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
|
||||
}},
|
||||
}
|
||||
if shouldIncludeTestFkCheck {
|
||||
// Also a check for whether the Save function returns an error
|
||||
return &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NoError")},
|
||||
Args: []ast.Expr{ast.NewIdent("t"), mainExpr},
|
||||
}
|
||||
}
|
||||
return mainExpr
|
||||
}()},
|
||||
)
|
||||
|
||||
// require.NotZero(t, item.ID)
|
||||
&ast.ExprStmt{X: &ast.CallExpr{
|
||||
if !tbl.IsWithoutRowid { // non-rowid tables don't get an ID
|
||||
stmts = append(stmts, &ast.ExprStmt{X: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NotZero")},
|
||||
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
|
||||
}},
|
||||
}})
|
||||
}
|
||||
|
||||
// After create: assert timestamps are set
|
||||
@@ -112,63 +379,48 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
BlankLine(),
|
||||
Comment("Load"),
|
||||
|
||||
// item2 := Must(TestDB.GetItemByID(item.ID))
|
||||
// item2 := must.Get(TestDB.GetItemByID(item.ID))
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{testObj2},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
|
||||
})},
|
||||
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
|
||||
},
|
||||
|
||||
// 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},
|
||||
},
|
||||
}},
|
||||
// if deep.Equal(...) {...}
|
||||
makeDeepEqual(testObj, testObj2),
|
||||
)
|
||||
|
||||
stmts = append(stmts,
|
||||
BlankLine(),
|
||||
Comment("Update"),
|
||||
)
|
||||
|
||||
// item.Description = "a big item"
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
|
||||
// item.Description = <sample value, offset 2>
|
||||
// ...one assignment per updatable field
|
||||
for _, c := range updateTestFields {
|
||||
stmts = append(stmts, &ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}},
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
|
||||
},
|
||||
Rhs: []ast.Expr{SampleValue(c, 2)},
|
||||
})
|
||||
}
|
||||
|
||||
stmts = append(stmts,
|
||||
// TestDB.SaveItem(&item)
|
||||
&ast.ExprStmt{X: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
|
||||
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
|
||||
}},
|
||||
|
||||
// item2 = Must(TestDB.GetItemByID(item.ID))
|
||||
// item2 = must.Get(TestDB.GetItemByID(item.ID))
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{testObj2},
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
|
||||
})},
|
||||
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
|
||||
},
|
||||
|
||||
// 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},
|
||||
},
|
||||
}},
|
||||
// if deep.Equal(...) {...}
|
||||
makeDeepEqual(testObj, testObj2),
|
||||
)
|
||||
|
||||
indexGets, hasIndexedGets := []ast.Stmt{
|
||||
@@ -181,8 +433,21 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
// Skip indexes on other tables
|
||||
continue
|
||||
}
|
||||
if index.IsUnique && len(index.Columns) == 1 {
|
||||
col := tbl.GetColumnByName(index.Columns[0])
|
||||
if slices.Contains(index.Columns, "") {
|
||||
// Skip expression indexes; there's no way to resolve an expression to a real column
|
||||
continue
|
||||
}
|
||||
cols := make([]pkgschema.Column, len(index.Columns))
|
||||
nameParts := make([]string, len(index.Columns))
|
||||
callArgs := make([]ast.Expr, len(index.Columns))
|
||||
for i, colName := range index.Columns {
|
||||
cols[i] = tbl.GetColumnByName(colName)
|
||||
nameParts[i] = cols[i].GoFieldName()
|
||||
callArgs[i] = &ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(cols[i].GoFieldName())}
|
||||
}
|
||||
funcNameSuffix := strings.Join(nameParts, "And")
|
||||
|
||||
if index.IsUnique {
|
||||
indexGets = append(indexGets, []ast.Stmt{
|
||||
// assert.Equal(t, item2, TestDB.GetItemByXYZ(...))
|
||||
&ast.ExprStmt{X: &ast.CallExpr{ // TODO: what if just delete the "ExprStmt" wrapper?
|
||||
@@ -191,15 +456,33 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
ast.NewIdent("t"),
|
||||
testObj2,
|
||||
mustCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName())},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}},
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
||||
"Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + funcNameSuffix,
|
||||
)},
|
||||
Args: callArgs,
|
||||
}),
|
||||
},
|
||||
}},
|
||||
}...)
|
||||
// decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
|
||||
hasIndexedGets = true
|
||||
} else {
|
||||
indexGets = append(indexGets, []ast.Stmt{
|
||||
// assert.Contains(t, TestDB.GetItemsByXYZ(...), item2)
|
||||
&ast.ExprStmt{X: &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Contains")},
|
||||
Args: []ast.Expr{
|
||||
ast.NewIdent("t"),
|
||||
&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
||||
"Get" + inflection.Plural(pkgschema.TypenameFromTablename(tbl.TableName)) + "By" + funcNameSuffix,
|
||||
)},
|
||||
Args: callArgs,
|
||||
},
|
||||
testObj2,
|
||||
},
|
||||
}},
|
||||
}...)
|
||||
}
|
||||
hasIndexedGets = true
|
||||
}
|
||||
|
||||
if hasIndexedGets {
|
||||
@@ -220,10 +503,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
|
||||
}},
|
||||
Rhs: []ast.Expr{getItemByPKCall(testObj)},
|
||||
},
|
||||
|
||||
// assert.ErrorIs(t, err, db.ErrNotInDB)
|
||||
@@ -261,92 +541,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{
|
||||
makeItemFunc,
|
||||
testCreateUpdateDelete,
|
||||
@@ -367,13 +561,13 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
Name: ast.NewIdent("."),
|
||||
},
|
||||
&ast.ImportSpec{
|
||||
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
|
||||
Name: ast.NewIdent("."),
|
||||
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`},
|
||||
},
|
||||
&ast.ImportSpec{
|
||||
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
|
||||
Name: ast.NewIdent("."),
|
||||
},
|
||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/go-test/deep"`}},
|
||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}},
|
||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"text/template"
|
||||
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
)
|
||||
|
||||
//go:embed "tpl"
|
||||
@@ -22,31 +22,31 @@ type PkgOpts struct {
|
||||
func InitPkg(opts PkgOpts) {
|
||||
// Run `go mod init`
|
||||
fmt.Printf("Running... `go mod init %s`\n", opts.ModuleName)
|
||||
PanicIf(exec.Command("go", "mod", "init", opts.ModuleName).Run())
|
||||
must.Do(exec.Command("go", "mod", "init", opts.ModuleName).Run())
|
||||
|
||||
// Run `git init`, if required
|
||||
if exec.Command("git", "status").Run() != nil {
|
||||
// Not in a git repo yet; init one
|
||||
fmt.Println("Running... `git init`")
|
||||
PanicIf(exec.Command("git", "init").Run())
|
||||
must.Do(exec.Command("git", "init").Run())
|
||||
}
|
||||
|
||||
// Create package structure
|
||||
PanicIf(os.MkdirAll("pkg/db", 0o755))
|
||||
PanicIf(os.MkdirAll("cmd", 0o755))
|
||||
PanicIf(os.MkdirAll("doc", 0o755))
|
||||
PanicIf(os.MkdirAll("sample_data", 0o755))
|
||||
must.Do(os.MkdirAll("pkg/db", 0o755))
|
||||
must.Do(os.MkdirAll("cmd", 0o755))
|
||||
must.Do(os.MkdirAll("doc", 0o755))
|
||||
must.Do(os.MkdirAll("sample_data", 0o755))
|
||||
|
||||
PanicIf(os.WriteFile("pkg/db/schema.sql", Must(tpl.ReadFile("tpl/schema.sql")), 0o664))
|
||||
PanicIf(os.WriteFile("pkg/db/db.go", Must(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
|
||||
must.Do(os.WriteFile("pkg/db/schema.sql", must.Get(tpl.ReadFile("tpl/schema.sql")), 0o664))
|
||||
must.Do(os.WriteFile("pkg/db/db.go", must.Get(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
|
||||
|
||||
dbTest := Must(os.Create("pkg/db/db_test.go"))
|
||||
defer MustClose(dbTest)
|
||||
t := Must(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
|
||||
PanicIf(t.Execute(dbTest, opts))
|
||||
dbTest := must.Get(os.Create("pkg/db/db_test.go"))
|
||||
defer must.Close(dbTest)
|
||||
t := must.Get(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
|
||||
must.Do(t.Execute(dbTest, opts))
|
||||
|
||||
PanicIf(os.WriteFile("sample_data/mount.sh", Must(tpl.ReadFile("tpl/mount.sh")), 0o775))
|
||||
PanicIf(os.WriteFile("sample_data/reset.sh", Must(tpl.ReadFile("tpl/reset.sh")), 0o775))
|
||||
must.Do(os.WriteFile("sample_data/mount.sh", must.Get(tpl.ReadFile("tpl/mount.sh")), 0o775))
|
||||
must.Do(os.WriteFile("sample_data/reset.sh", must.Get(tpl.ReadFile("tpl/reset.sh")), 0o775))
|
||||
|
||||
// TODO:
|
||||
// - create `pkg/db/errors.go`
|
||||
|
||||
@@ -3,7 +3,7 @@ package db_test
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
|
||||
. "{{ .ModuleName }}/pkg/db"
|
||||
)
|
||||
@@ -14,6 +14,6 @@ func init() {
|
||||
TestDB = MakeDB("tmp")
|
||||
}
|
||||
func MakeDB(dbName string) *DB {
|
||||
db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName)))
|
||||
db := must.Get(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName)))
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package flowutils
|
||||
|
||||
import "io"
|
||||
|
||||
func PanicIf(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Must[T any](val T, err error) T {
|
||||
PanicIf(err)
|
||||
return val
|
||||
}
|
||||
|
||||
func MustClose(closer io.Closer) {
|
||||
PanicIf(closer.Close())
|
||||
}
|
||||
27
pkg/must/must.go
Normal file
27
pkg/must/must.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package must
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func Do(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Get[T any](val T, err error) T {
|
||||
Do(err)
|
||||
return val
|
||||
}
|
||||
|
||||
func Be(condition bool, msg string, args ...any) {
|
||||
if !condition {
|
||||
panic(fmt.Errorf(msg, args...)) //nolint:err113 // not a returned error
|
||||
}
|
||||
}
|
||||
|
||||
func Close(closer io.Closer) {
|
||||
Do(closer.Close())
|
||||
}
|
||||
67
pkg/schema/column.go
Normal file
67
pkg/schema/column.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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 is the name of the SQLite table this column belongs to.
|
||||
TableName string `db:"table_name"`
|
||||
|
||||
// Name is the SQLite column name.
|
||||
Name string `db:"column_name"`
|
||||
|
||||
// Type is the SQLite type this column contains.
|
||||
Type string `db:"column_type"`
|
||||
|
||||
IsNotNull bool `db:"notnull"`
|
||||
HasDefaultValue bool `db:"has_default_value"`
|
||||
DefaultValue string `db:"dflt_value"`
|
||||
IsPrimaryKey bool `db:"is_primary_key"`
|
||||
PrimaryKeyRank uint `db:"primary_key_rank"`
|
||||
IsForeignKey bool `db:"is_foreign_key"`
|
||||
ForeignKeyTargetTable string `db:"fk_target_table"`
|
||||
ForeignKeyTargetColumn string `db:"fk_target_column"`
|
||||
}
|
||||
|
||||
// IsNullableForeignKey is a helper function.
|
||||
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)
|
||||
}
|
||||
|
||||
// GoVarName returns the name of a local variable for this column, e.g., when used as a function parameter.
|
||||
func (c Column) GoVarName() string {
|
||||
if c.Name == "rowid" {
|
||||
return strings.ToLower(c.TableName)[0:1] + "ID"
|
||||
// TODO: Or should it just be "id"??
|
||||
}
|
||||
// For foreign keys, use first letter of the target type and "ID". "UserID" => "uID"
|
||||
if c.IsNonCodeTableForeignKey() {
|
||||
return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID"
|
||||
}
|
||||
|
||||
// Otherwise, just use the whole name
|
||||
return c.LongGoVarName()
|
||||
}
|
||||
|
||||
// LongGoVarName returns a lowercased version of the field name (Pascal => Camel).
|
||||
func (c Column) LongGoVarName() string {
|
||||
return textutils.CamelToPascal(c.GoFieldName())
|
||||
}
|
||||
10
pkg/schema/index.go
Normal file
10
pkg/schema/index.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package schema
|
||||
|
||||
type Index struct {
|
||||
Name string `db:"index_name"`
|
||||
TableName string `db:"table_name"`
|
||||
Columns []string
|
||||
IsUnique bool `db:"is_unique"`
|
||||
// TODO: `where ...` for partial indexes
|
||||
// TODO: identify columns that are expressions
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"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/must"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
alter table t1 add column data3 integer;
|
||||
`
|
||||
db2Config := db.Init(&baseSchema, &[]string{migration})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
`
|
||||
|
||||
db2Config := db.Init(&baseSchema, &[]string{migration1, migration2})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
|
||||
t.Run("missing migration", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]string{})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
rowid integer primary key
|
||||
);
|
||||
`})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
);
|
||||
alter table t1 add column data3 text;
|
||||
`})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
@@ -38,20 +38,20 @@ func SchemaFromDB(db *sqlx.DB) Schema {
|
||||
db.MustExec(create_views)
|
||||
|
||||
var tables []Table
|
||||
PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
|
||||
must.Do(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
|
||||
for _, tbl := range tables {
|
||||
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
|
||||
tbl.TypeIDName = tbl.GoTypeName + "ID"
|
||||
tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
|
||||
|
||||
PanicIf(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
|
||||
must.Do(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
|
||||
ret.Tables[tbl.TableName] = tbl
|
||||
}
|
||||
|
||||
var indexes []Index
|
||||
PanicIf(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
|
||||
must.Do(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
|
||||
for _, idx := range indexes {
|
||||
PanicIf(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
|
||||
must.Do(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
|
||||
ret.Indexes[idx.Name] = idx
|
||||
}
|
||||
return ret
|
||||
|
||||
6
pkg/schema/schema.go
Normal file
6
pkg/schema/schema.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package schema
|
||||
|
||||
type Schema struct {
|
||||
Tables map[string]Table
|
||||
Indexes map[string]Index
|
||||
}
|
||||
@@ -1,85 +1,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Column represents a single column in a table.
|
||||
type Column struct {
|
||||
TableName string `db:"table_name"`
|
||||
Name string `db:"column_name"`
|
||||
Type string `db:"column_type"`
|
||||
IsNotNull bool `db:"notnull"`
|
||||
HasDefaultValue bool `db:"has_default_value"`
|
||||
DefaultValue string `db:"dflt_value"`
|
||||
IsPrimaryKey bool `db:"is_primary_key"`
|
||||
PrimaryKeyRank uint `db:"primary_key_rank"`
|
||||
IsForeignKey bool `db:"is_foreign_key"`
|
||||
ForeignKeyTargetTable string `db:"fk_target_table"`
|
||||
ForeignKeyTargetColumn string `db:"fk_target_column"`
|
||||
}
|
||||
|
||||
// IsNullableForeignKey is a helper function.
|
||||
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)
|
||||
}
|
||||
|
||||
// GoVarName returns the name of a local variable for this column, e.g., when used as a function parameter.
|
||||
func (c Column) GoVarName() string {
|
||||
if c.Name == "rowid" {
|
||||
return strings.ToLower(c.TableName)[0:1] + "ID"
|
||||
// TODO: Or should it just be "id"??
|
||||
}
|
||||
// For foreign keys, use first letter of the target type and "ID". "UserID" => "uID"
|
||||
if c.IsNonCodeTableForeignKey() {
|
||||
return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID"
|
||||
}
|
||||
|
||||
// Otherwise, just lowercase the field name
|
||||
fieldname := c.GoFieldName()
|
||||
return strings.ToLower(fieldname)[0:1] + fieldname[1:]
|
||||
}
|
||||
|
||||
func (c Column) GoTypeName() 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"`
|
||||
@@ -138,16 +67,10 @@ func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
|
||||
return
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
Name string `db:"index_name"`
|
||||
TableName string `db:"table_name"`
|
||||
Columns []string
|
||||
IsUnique bool `db:"is_unique"`
|
||||
// TODO: `where ...` for partial indexes
|
||||
// TODO: identify columns that are expressions
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
Tables map[string]Table
|
||||
Indexes map[string]Index
|
||||
func (t Table) GetCodeTableValues(db *sqlx.DB) (ret []string) {
|
||||
if !slices.ContainsFunc(t.Columns, func(c Column) bool { return c.Name == "name" }) {
|
||||
panic("not a code table")
|
||||
}
|
||||
flowutils.PanicIf(db.Select(&ret, fmt.Sprintf("select name from %s", t.TableName)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,3 +9,17 @@ func SnakeToCamel(s string) string {
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func KebabToPascal(s string) string {
|
||||
parts := strings.Split(s, "-")
|
||||
for i, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func CamelToPascal(s string) string {
|
||||
return strings.ToLower(s)[0:1] + s[1:]
|
||||
}
|
||||
|
||||
8
sample_data/test_schemas/codetables.sql
Normal file
8
sample_data/test_schemas/codetables.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
create table item_types (
|
||||
rowid integer primary key,
|
||||
name text not null unique
|
||||
) strict;
|
||||
insert into item_types(rowid, name) values
|
||||
(1, 'first-type'),
|
||||
(2, 'second-type'),
|
||||
(3, 'third-type');
|
||||
Reference in New Issue
Block a user