Compare commits
47 Commits
e53546a7f5
...
v0.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| de11a0214b | |||
| 936df98cc4 | |||
| 56b264fcb3 | |||
| 99b57c88b0 | |||
| 6cf0b66dd5 | |||
| f98f8416e8 | |||
| 1592c7d1fc | |||
| bd5ad7e77b | |||
| 171da4139b | |||
| 741709623b | |||
| b064948d24 | |||
| 9d4a1eab10 | |||
| 40bc0db036 | |||
|
|
ff7f0a57c8 | ||
| d2b3a8173f | |||
| ba89f0b818 | |||
| 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 |
@@ -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
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,12 +18,16 @@ const (
|
||||
func main() {
|
||||
root_cmd := &cobra.Command{
|
||||
Use: "gas",
|
||||
Version: version.String(),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
// Print a bare version string ("v0.0.4"), rather than cobra's "gas version v0.0.4".
|
||||
root_cmd.SetVersionTemplate("{{.Version}}\n")
|
||||
root_cmd.AddCommand(sqlite_lint)
|
||||
root_cmd.AddCommand(cmd_init)
|
||||
root_cmd.AddCommand(generate_model)
|
||||
root_cmd.AddCommand(cmd_version)
|
||||
if err := root_cmd.Execute(); err != nil {
|
||||
fmt.Println(RED + err.Error() + RESET)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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,40 @@ var generate_model = &cobra.Command{
|
||||
modelgenerate.GenerateSQLFieldsConst(table),
|
||||
modelgenerate.GenerateSaveItemFunc(table),
|
||||
modelgenerate.GenerateDeleteItemFunc(table),
|
||||
)
|
||||
if table.HasSoftDelete() {
|
||||
decls = append(decls, modelgenerate.GenerateSoftDeleteItemFunc(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
|
||||
}
|
||||
if index.Origin == "pk" && table.IsWithoutRowid {
|
||||
// Already generated above, as the primary-key getter
|
||||
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,7 +111,7 @@ var generate_model = &cobra.Command{
|
||||
Decls: decls,
|
||||
}
|
||||
|
||||
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
must.Do(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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)
|
||||
|
||||
19
cmd/subcmd_version.go
Normal file
19
cmd/subcmd_version.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
|
||||
)
|
||||
|
||||
var cmd_version = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version of `gas`",
|
||||
Args: cobra.NoArgs,
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(version.String())
|
||||
},
|
||||
}
|
||||
25
doc/TODO.txt
25
doc/TODO.txt
@@ -1,13 +1,6 @@
|
||||
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
|
||||
- GetXyzByID should include `ErrItemIsDeleted` if item is soft-deleted
|
||||
|
||||
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)
|
||||
@@ -22,16 +15,26 @@ 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 X for Y"
|
||||
|
||||
TODO: create a Getter function for each unique index
|
||||
- Example: `chat_pinned_messages`
|
||||
|
||||
TODO: consider CreateDB and CreateMemoryDB
|
||||
|
||||
TODO: generate tests for the remaining CRUD, try to hit ~100% default test coverage
|
||||
|
||||
TODO: test reporter
|
||||
- coverage: https://x.com/wispem_wantex/status/2009799380648505563
|
||||
- Jest shows for each file: % of lines, functions, branches and statements covered
|
||||
- Go reporter should show:
|
||||
- group coverage stats by either function, file or package
|
||||
- count by statements, branches or functions
|
||||
- Show both raw count and percentage
|
||||
- list which lines / branches / functions were missed
|
||||
- pretty (terminal) format or JSON format
|
||||
|
||||
TODO: foreign key to compound-FK table
|
||||
- sample: booru_post_tag_votes
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
|
||||
- `ops` directory
|
||||
|
||||
### Reinventing the wheel
|
||||
|
||||
Wheels aren't difficult to invent; the concept is very simple. However, there's many different types, shapes and sizes of wheels. The wheel on a shopping cart is different from the wheel on a bicycle, a car, a tractor, a train, a spool of thread, etc.
|
||||
|
||||
Reinventing the wheel is good. It's good to use things that are specifically built for your purpose, with exactly the set of features and affordances you need. Using a wheel that was made by someone else for a different purpose can be faster at first, but it's likely that their needs were different from yours.
|
||||
|
||||
### Borrowed from Ruby On Rails
|
||||
|
||||
- Convention Over Configuration
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
https://astexplorer.net/
|
||||
https://github.com/dave/dst
|
||||
https://disaev.me/p/writing-useful-go-analysis-linter/
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
#!/bin/sh
|
||||
|
||||
go build -tags fts5 -o gas ./cmd
|
||||
# Stamp the version into the binary. `git describe` fails when this isn't a git checkout (e.g. a
|
||||
# source export with no .git dir); in that case leave it empty, and pkg/version falls back to the
|
||||
# build info that the Go toolchain embeds.
|
||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null)
|
||||
|
||||
go build -tags fts5 \
|
||||
-ldflags "-X git.offline-twitter.com/offline-labs/gas-stack/pkg/version.Version=$VERSION" \
|
||||
-o gas ./cmd
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# A dev container.
|
||||
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
|
||||
|
||||
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
|
||||
RUN apk add less
|
||||
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2
|
||||
RUN curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b /usr/local/bin v2.14.0
|
||||
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
||||
|
||||
COPY etc/group /etc/group
|
||||
|
||||
@@ -10,4 +10,5 @@ sudo docker run --rm -it \
|
||||
-v /memory:/memory \
|
||||
--workdir /code \
|
||||
--net host \
|
||||
--name gas \
|
||||
gas
|
||||
|
||||
@@ -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
Executable file
3
ops/test.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
go test -tags fts5 ./...
|
||||
@@ -27,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},
|
||||
}
|
||||
}
|
||||
@@ -73,10 +81,15 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
||||
fset = token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-parsing pretty-print: %w", 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 {
|
||||
@@ -91,13 +104,15 @@ 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, isOk := TrailingComments[orig]
|
||||
if !isOk {
|
||||
|
||||
@@ -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")}})
|
||||
@@ -214,7 +297,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
|
||||
// Assemble data for building SQL "insert" and "update" strings
|
||||
// Assemble SQL "insert" string
|
||||
for _, col := range tbl.Columns {
|
||||
if col.Name == "rowid" {
|
||||
continue
|
||||
@@ -225,14 +308,34 @@ 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
|
||||
}
|
||||
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, ", "),
|
||||
)
|
||||
// ...and "update" string
|
||||
for _, col := range tbl.GetUpdateableColumns() {
|
||||
val := ":" + col.Name
|
||||
if col.IsNullableForeignKey() {
|
||||
val = fmt.Sprintf("nullif(%s, 0)", val)
|
||||
}
|
||||
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 "))
|
||||
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 +353,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 +379,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 +425,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 +498,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")}})
|
||||
@@ -391,11 +516,16 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
}(),
|
||||
}
|
||||
|
||||
funcDecl := &ast.FuncDecl{
|
||||
Doc: &ast.CommentGroup{List: []*ast.Comment{
|
||||
docLines := []*ast.Comment{
|
||||
{Text: fmt.Sprintf("// Save%s creates or updates a %s in the database.", tbl.GoTypeName, tbl.GoTypeName)},
|
||||
{Text: "// If the item doesn't exist (has no ID set), it will create it; otherwise it will do an update."},
|
||||
}},
|
||||
}
|
||||
if tbl.IsWithoutRowid {
|
||||
docLines = append(docLines, &ast.Comment{Text: "// If a row with the same key already exists, it will be updated; otherwise it will be created."})
|
||||
} else {
|
||||
docLines = append(docLines, &ast.Comment{Text: "// If the item doesn't exist (has no ID set), it will create it; otherwise it will do an update."})
|
||||
}
|
||||
funcDecl := &ast.FuncDecl{
|
||||
Doc: &ast.CommentGroup{List: docLines},
|
||||
Recv: dbRecv,
|
||||
Name: ast.NewIdent("Save" + tbl.GoTypeName),
|
||||
Type: &ast.FuncType{
|
||||
@@ -416,6 +546,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 +669,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
|
||||
}
|
||||
|
||||
// GenerateGetItemByUniqColFunc 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 +711,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 +753,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 +770,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 +793,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,46 +806,89 @@ 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
|
||||
}
|
||||
|
||||
// GenerateSoftDeleteItemFunc produces an AST for the `SoftDeleteXyz()` function, which is only
|
||||
// generated for tables that have both an "is_deleted" and a "deleted_at" column.
|
||||
// E.g., a table with `table.TypeName = "foods"` will produce a "SoftDeleteFood()" function.
|
||||
func GenerateSoftDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
whereClauses := []string{}
|
||||
for _, c := range tbl.PrimaryKeyColumns() {
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("%s = :%s", c.Name, c.Name))
|
||||
}
|
||||
setPairs := []string{"is_deleted = :is_deleted", "deleted_at = :deleted_at"}
|
||||
|
||||
updateStmt := fmt.Sprintf("\n\t update %s\n\t set %s\n\t where %s\n\t",
|
||||
tbl.TableName,
|
||||
strings.Join(setPairs, ",\n\t "),
|
||||
strings.Join(whereClauses, " and "),
|
||||
)
|
||||
|
||||
funcBody := &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
// item.IsDeleted = true
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(tbl.GetColumnByName("is_deleted").GoFieldName())}},
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{ast.NewIdent("true")},
|
||||
},
|
||||
// item.DeletedAt = TimestampNow()
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(tbl.GetColumnByName("deleted_at").GoFieldName())}},
|
||||
Tok: token.ASSIGN,
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
|
||||
},
|
||||
// result := must.Get(db.DB.NamedExec(...))
|
||||
&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),
|
||||
},
|
||||
})},
|
||||
},
|
||||
MustBeRowsAffected(tbl),
|
||||
},
|
||||
}
|
||||
|
||||
return &ast.FuncDecl{
|
||||
Doc: &ast.CommentGroup{List: []*ast.Comment{
|
||||
{Text: fmt.Sprintf("// SoftDelete%s sets deleted-at and marks the item as deleted.", tbl.GoTypeName)},
|
||||
}},
|
||||
Recv: dbRecv,
|
||||
Name: ast.NewIdent("SoftDelete" + 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},
|
||||
Body: funcBody,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSQLFieldsConst produces an AST for the `const xyzSQLFields = ...` string.
|
||||
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
|
||||
columns := make([]string, 0, len(tbl.Columns))
|
||||
|
||||
@@ -4,22 +4,144 @@ 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 MakeXyz() and the create/update test. It starts with Table.GetUpdateableColumns()
|
||||
// and removes fields the generated test should not assign directly: foreign keys and auto-managed
|
||||
// updated_at.
|
||||
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
||||
_, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
for _, c := range tbl.GetUpdateableColumns() {
|
||||
if c.IsForeignKey {
|
||||
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)
|
||||
uniqueColumns := map[string]bool{}
|
||||
for _, index := range schema.Indexes {
|
||||
if index.TableName != tbl.TableName || !index.IsUnique {
|
||||
continue
|
||||
}
|
||||
for _, columnName := range index.Columns {
|
||||
uniqueColumns[columnName] = true
|
||||
}
|
||||
}
|
||||
hasRandomUniqueField := false
|
||||
for _, c := range updateTestFields {
|
||||
if uniqueColumns[c.Name] {
|
||||
hasRandomUniqueField = true
|
||||
break
|
||||
}
|
||||
}
|
||||
requiredForeignKeys := []pkgschema.Column{}
|
||||
for _, c := range tbl.Columns {
|
||||
// Only ordinary object foreign keys use a distinct TargetObjectID Go type;
|
||||
// code-table foreign keys retain their underlying SQLite type in the generated model.
|
||||
if c.IsNotNull && c.IsNonCodeTableForeignKey() {
|
||||
requiredForeignKeys = append(requiredForeignKeys, c)
|
||||
}
|
||||
}
|
||||
requiredForeignKeyParamNames := paramNamesFor(requiredForeignKeys)
|
||||
|
||||
// Calls made by this generated test do not create related objects themselves. Pass a
|
||||
// zero ID so the generated test remains syntactically valid; callers of MakeXyz provide
|
||||
// IDs of the related objects they create.
|
||||
makeItemArgs := func() (ret []ast.Expr) {
|
||||
for range requiredForeignKeys {
|
||||
ret = append(ret, &ast.BasicLit{Kind: token.INT, Value: "0"})
|
||||
}
|
||||
return
|
||||
}()
|
||||
|
||||
// func MakeItem() Item { return Item{} }
|
||||
makeItemFunc := &ast.FuncDecl{
|
||||
Name: ast.NewIdent("Make" + tbl.GoTypeName),
|
||||
Name: makeHelperName,
|
||||
Type: &ast.FuncType{
|
||||
Params: &ast.FieldList{},
|
||||
Params: &ast.FieldList{List: func() (ret []*ast.Field) {
|
||||
for i, c := range requiredForeignKeys {
|
||||
ret = append(ret, &ast.Field{
|
||||
Names: []*ast.Ident{ast.NewIdent(requiredForeignKeyParamNames[i])},
|
||||
Type: GoTypeForColumn(c),
|
||||
})
|
||||
}
|
||||
return
|
||||
}()},
|
||||
Results: &ast.FieldList{
|
||||
List: []*ast.Field{
|
||||
{Type: ast.NewIdent(tbl.GoTypeName)},
|
||||
@@ -27,26 +149,142 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
},
|
||||
},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
List: append(
|
||||
func() []ast.Stmt {
|
||||
if !hasRandomUniqueField {
|
||||
return nil
|
||||
}
|
||||
return []ast.Stmt{&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{ast.NewIdent("id")},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("rand"), Sel: ast.NewIdent("Int")}}},
|
||||
}}
|
||||
}(),
|
||||
[]ast.Stmt{
|
||||
&ast.ReturnStmt{
|
||||
Results: []ast.Expr{
|
||||
&ast.CompositeLit{
|
||||
Type: ast.NewIdent(tbl.GoTypeName),
|
||||
Elts: func() (ret []ast.Expr) {
|
||||
// `UpdateTestFields()` skips foreign keys, so this will not cause duplicates
|
||||
for i, c := range requiredForeignKeys {
|
||||
ret = append(ret, &ast.KeyValueExpr{
|
||||
Key: ast.NewIdent(c.GoFieldName()),
|
||||
Value: ast.NewIdent(requiredForeignKeyParamNames[i]),
|
||||
})
|
||||
}
|
||||
for _, c := range updateTestFields {
|
||||
value := SampleValue(c, 0)
|
||||
if uniqueColumns[c.Name] {
|
||||
switch c.Type {
|
||||
case "integer", "int":
|
||||
value = ast.NewIdent("id")
|
||||
case "text":
|
||||
value = &ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")},
|
||||
Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `"%s-%d"`},
|
||||
SampleValue(c, 0),
|
||||
ast.NewIdent("id"),
|
||||
},
|
||||
}
|
||||
case "blob":
|
||||
value = &ast.CallExpr{
|
||||
Fun: &ast.ArrayType{Elt: ast.NewIdent("byte")},
|
||||
Args: []ast.Expr{&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")},
|
||||
Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `"%s-%d"`},
|
||||
&ast.CallExpr{Fun: ast.NewIdent("string"), Args: []ast.Expr{SampleValue(c, 0)}},
|
||||
ast.NewIdent("id"),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
ret = append(ret, &ast.KeyValueExpr{
|
||||
Key: ast.NewIdent(c.GoFieldName()),
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
return
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}...,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
|
||||
testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
|
||||
testDB := ast.NewIdent("TestDB")
|
||||
|
||||
// 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testObj := ast.NewIdent("item")
|
||||
testObj2 := ast.NewIdent("item2")
|
||||
fieldName := ast.NewIdent("Description")
|
||||
description1 := `"an item"`
|
||||
description2 := `"a big item"`
|
||||
testDB := ast.NewIdent("TestDB")
|
||||
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
}
|
||||
|
||||
testFuncType := &ast.FuncType{
|
||||
Params: &ast.FieldList{
|
||||
@@ -57,6 +295,104 @@ 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),
|
||||
Args: makeItemArgs,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// `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 +408,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: makeItemArgs}},
|
||||
},
|
||||
// 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,64 +467,51 @@ 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),
|
||||
)
|
||||
|
||||
if len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
|
||||
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{
|
||||
BlankLine(),
|
||||
@@ -181,8 +523,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?
|
||||
@@ -192,16 +547,33 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
testObj2,
|
||||
mustCall(&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
||||
"Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName(),
|
||||
"Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + funcNameSuffix,
|
||||
)},
|
||||
Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}},
|
||||
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 {
|
||||
@@ -222,10 +594,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)
|
||||
@@ -263,92 +632,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,
|
||||
@@ -362,23 +645,33 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
Decls: append([]ast.Decl{
|
||||
&ast.GenDecl{
|
||||
Tok: token.IMPORT,
|
||||
Specs: []ast.Spec{
|
||||
Specs: func() []ast.Spec {
|
||||
specs := []ast.Spec{}
|
||||
if hasRandomUniqueField {
|
||||
specs = append(specs,
|
||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
|
||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"math/rand"`}},
|
||||
)
|
||||
}
|
||||
specs = append(specs, []ast.Spec{
|
||||
&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("."),
|
||||
},
|
||||
&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"`}},
|
||||
},
|
||||
}...)
|
||||
return specs
|
||||
}(),
|
||||
},
|
||||
}, testList...),
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
var sql_schema string
|
||||
|
||||
// Database starts at version 0. First migration brings us to version 1
|
||||
var MIGRATIONS = []string{}
|
||||
var MIGRATIONS = []db.Migration{}
|
||||
|
||||
type DB struct {
|
||||
DB *sqlx.DB
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
type DBConfig struct {
|
||||
@@ -18,25 +20,11 @@ type DBConfig struct {
|
||||
sql_schema *string
|
||||
|
||||
// Database starts at version 0. First migration brings us to version 1
|
||||
migrations *[]string
|
||||
migrations *[]Migration
|
||||
version_number uint
|
||||
}
|
||||
|
||||
// Colors for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorBlack = "\033[30m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[37m"
|
||||
ColorWhite = "\033[97m"
|
||||
)
|
||||
|
||||
func Init(schema *string, migrationsList *[]string) DBConfig {
|
||||
func Init(schema *string, migrationsList *[]Migration) DBConfig {
|
||||
return DBConfig{
|
||||
sql_schema: schema,
|
||||
migrations: migrationsList,
|
||||
@@ -96,45 +84,32 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
|
||||
}
|
||||
|
||||
if c.version_number > version {
|
||||
fmt.Print(ColorYellow)
|
||||
fmt.Print(textutils.ColorYellow)
|
||||
fmt.Printf("================================================\n")
|
||||
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
|
||||
c.version_number)
|
||||
fmt.Print(ColorReset)
|
||||
c.UpgradeFromXToY(db, version, c.version_number)
|
||||
fmt.Print(textutils.ColorReset)
|
||||
|
||||
for i := version; i < c.version_number; i++ {
|
||||
m := (*c.migrations)[i]
|
||||
if err := m.Apply(db); err != nil {
|
||||
return fmt.Errorf("migration %d: %w", m.ID, err)
|
||||
}
|
||||
|
||||
fmt.Print(textutils.ColorYellow)
|
||||
fmt.Printf("Now at database schema version %d.\n", m.ID)
|
||||
fmt.Print(textutils.ColorReset)
|
||||
}
|
||||
|
||||
fmt.Print(textutils.ColorGreen)
|
||||
fmt.Printf("================================================\n")
|
||||
fmt.Printf("Database version has been upgraded to version %d.\n", c.version_number)
|
||||
fmt.Print(textutils.ColorReset)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpgradeFromXToY runs all the migrations from version X to version Y, and update the `database_version` table's `version_number`
|
||||
func (c DBConfig) UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
|
||||
for i := x; i < y; i++ {
|
||||
fmt.Print(ColorCyan)
|
||||
fmt.Println((*c.migrations)[i])
|
||||
fmt.Print(ColorReset)
|
||||
|
||||
// Execute the migration in a transaction
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tx.MustExec((*c.migrations)[i])
|
||||
tx.MustExec("update db_version set version = ?", i+1)
|
||||
if err := tx.Commit(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Print(ColorYellow)
|
||||
fmt.Printf("Now at database schema version %d.\n", i+1)
|
||||
fmt.Print(ColorReset)
|
||||
}
|
||||
fmt.Print(ColorGreen)
|
||||
fmt.Printf("================================================\n")
|
||||
fmt.Printf("Database version has been upgraded to version %d.\n", y)
|
||||
fmt.Print(ColorReset)
|
||||
}
|
||||
|
||||
type VersionMismatchError struct {
|
||||
EngineVersion uint
|
||||
DatabaseVersion uint
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestCreateAndConnectToDB(t *testing.T) {
|
||||
_schema_sql, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
|
||||
require.NoError(t, err)
|
||||
schema_sql := string(_schema_sql)
|
||||
migrations := []string{}
|
||||
migrations := []db.Migration{}
|
||||
|
||||
config := db.Init(&schema_sql, &migrations)
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestVersionUpgrade(t *testing.T) {
|
||||
create table db_version (version integer primary key) strict, without rowid;
|
||||
insert into db_version values(0);
|
||||
`
|
||||
migrations := []string{}
|
||||
migrations := []db.Migration{}
|
||||
config := db.Init(&initial_schema, &migrations)
|
||||
|
||||
connection, err := config.Create(":memory:")
|
||||
@@ -61,9 +61,9 @@ func TestVersionUpgrade(t *testing.T) {
|
||||
require.Equal(0, get_version(connection))
|
||||
|
||||
// Create a migration to add a new Item
|
||||
migrations = append(migrations, "insert into items (rowid) values (1)")
|
||||
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
migrations = append(migrations, db.Migration{ID: 1, SQL: "insert into items (rowid) values (1)"})
|
||||
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
require.NoError(config.CheckAndUpdateVersion(connection))
|
||||
|
||||
var items2 []int
|
||||
require.NoError(connection.Select(&items2, "select * from items"))
|
||||
@@ -71,9 +71,9 @@ func TestVersionUpgrade(t *testing.T) {
|
||||
require.Equal(1, get_version(connection))
|
||||
|
||||
// Create a migration to add a new Item
|
||||
migrations = append(migrations, `alter table items add column name string default 'asdf'`)
|
||||
migrations = append(migrations, db.Migration{ID: 2, SQL: `alter table items add column name string default 'asdf'`})
|
||||
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
require.NoError(config.CheckAndUpdateVersion(connection))
|
||||
|
||||
var items3 []struct {
|
||||
ID uint64 `db:"rowid"`
|
||||
|
||||
@@ -12,6 +12,8 @@ var (
|
||||
ErrItemIsDeleted = errors.New("item is deleted")
|
||||
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
|
||||
ErrDatabaseAlreadyExists = errors.New("target already exists")
|
||||
|
||||
ErrMigrationOrder = errors.New("migration in wrong order")
|
||||
)
|
||||
|
||||
type ForeignKey interface {
|
||||
|
||||
50
pkg/db/migration.go
Normal file
50
pkg/db/migration.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
// ID is the version number that the schema will be at, after applying the migration.
|
||||
ID int
|
||||
|
||||
// Title is an optional name for the migration.
|
||||
Title string
|
||||
|
||||
// SQL is the body of the migration that will be executed.
|
||||
SQL string
|
||||
}
|
||||
|
||||
// Apply runs the migration in a transaction. Foreign key checking is delayed until right before committing the transaction.
|
||||
func (m Migration) Apply(db *sqlx.DB) error {
|
||||
fmt.Print(textutils.ColorCyan)
|
||||
fmt.Println(m.SQL)
|
||||
fmt.Print(textutils.ColorReset)
|
||||
|
||||
// Temporarily suspend foreign keys until the migration is finished
|
||||
db.MustExec("pragma foreign_keys = off")
|
||||
defer db.MustExec("pragma foreign_keys = on")
|
||||
|
||||
tx := db.MustBegin()
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Sanity: should initially be on the previous version
|
||||
var prevVersion int
|
||||
must.Do(tx.Get(&prevVersion, `select version from db_version`))
|
||||
if prevVersion != m.ID-1 {
|
||||
return fmt.Errorf("%w: tried to apply migration %d on DB version %d", ErrMigrationOrder, m.ID, prevVersion)
|
||||
}
|
||||
|
||||
// Execute the migration
|
||||
tx.MustExec(m.SQL)
|
||||
tx.MustExec("update db_version set version = ?", m.ID)
|
||||
must.Do(PragmaForeignKeyCheck(tx))
|
||||
must.Do(tx.Commit())
|
||||
|
||||
return nil
|
||||
}
|
||||
260
pkg/db/migration_test.go
Normal file
260
pkg/db/migration_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"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/must"
|
||||
)
|
||||
|
||||
// migrationTestSchema has a foreign key (`children.parent_id` => `parents.rowid`) along with one
|
||||
// valid parent/child pair, so that migrations can be written which either preserve or break
|
||||
// referential integrity.
|
||||
var migrationTestSchema = `
|
||||
create table db_version (version integer primary key) strict, without rowid;
|
||||
insert into db_version values (0);
|
||||
|
||||
create table parents (
|
||||
rowid integer primary key,
|
||||
name text not null
|
||||
);
|
||||
insert into parents (rowid, name) values (1, 'p1');
|
||||
|
||||
create table children (
|
||||
rowid integer primary key,
|
||||
parent_id integer not null references parents(rowid)
|
||||
);
|
||||
insert into children (rowid, parent_id) values (1, 1);
|
||||
`
|
||||
|
||||
func newMigrationTestDB() *sqlx.DB {
|
||||
return must.Get(db.Init(&migrationTestSchema, &[]db.Migration{}).Create(":memory:"))
|
||||
}
|
||||
|
||||
func assertForeignKeysAreGood(t *testing.T, conn *sqlx.DB) {
|
||||
t.Helper()
|
||||
|
||||
// FKs should be enabled and intact at all times
|
||||
var fkEnabled bool
|
||||
require.NoError(t, conn.Get(&fkEnabled, `pragma foreign_keys`))
|
||||
assert.True(t, fkEnabled)
|
||||
assert.NoError(t, db.PragmaForeignKeyCheck(conn))
|
||||
|
||||
// ...and enforcement should be in effect again, not merely reported as on
|
||||
_, err := conn.Exec(`insert into children (rowid, parent_id) values (99, 40235235234)`)
|
||||
assert.True(t, db.IsSqliteFkError(err), "expected a foreign key error, got: %v", err)
|
||||
}
|
||||
|
||||
func TestMigrationApply(t *testing.T) {
|
||||
conn := newMigrationTestDB()
|
||||
|
||||
testcases := []struct {
|
||||
Migration db.Migration
|
||||
ExpectedParents int
|
||||
ExpectedChildren int
|
||||
}{
|
||||
{
|
||||
Migration: db.Migration{
|
||||
ID: 1,
|
||||
Title: "add a second parent",
|
||||
SQL: `insert into parents (rowid, name) values (2, 'p2')`,
|
||||
},
|
||||
ExpectedParents: 2,
|
||||
ExpectedChildren: 1,
|
||||
},
|
||||
{
|
||||
Migration: db.Migration{ID: 2, SQL: `
|
||||
insert into children (rowid, parent_id) values (2, 2);
|
||||
insert into parents (rowid, name) values (3, 'p3');
|
||||
`},
|
||||
ExpectedParents: 3,
|
||||
ExpectedChildren: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.Migration.Title, func(t *testing.T) {
|
||||
require.NoError(t, tc.Migration.Apply(conn))
|
||||
|
||||
// Check that it applied
|
||||
var parents int
|
||||
require.NoError(t, conn.Get(&parents, `select count(*) from parents`))
|
||||
assert.Equal(t, tc.ExpectedParents, parents)
|
||||
var children int
|
||||
require.NoError(t, conn.Get(&children, `select count(*) from children`))
|
||||
assert.Equal(t, tc.ExpectedChildren, children)
|
||||
|
||||
// Should bump the DB version
|
||||
var version int
|
||||
require.NoError(t, conn.Get(&version, `select version from db_version`))
|
||||
assert.Equal(t, tc.Migration.ID, version, "db version should be bumped to the migration's ID")
|
||||
|
||||
assertForeignKeysAreGood(t, conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A migration whose SQL fails must leave no trace, including the statements which had already
|
||||
// succeeded before the failing one. Every migration below therefore starts by creating
|
||||
// `new_table`, which must never survive.
|
||||
func TestMigrationFailureRollsBack(t *testing.T) {
|
||||
conn := newMigrationTestDB()
|
||||
|
||||
nothingHappens := func(t *testing.T) {
|
||||
t.Helper()
|
||||
// Everything should be rolled back, including the statements which ran before the failing one
|
||||
var newTables int
|
||||
require.NoError(t, conn.Get(&newTables, `select count(*) from sqlite_master where type = 'table' and name = 'new_table'`))
|
||||
assert.Equal(t, 0, newTables, "the migration's earlier statements should be rolled back too")
|
||||
|
||||
var version int
|
||||
require.NoError(t, conn.Get(&version, `select version from db_version`))
|
||||
assert.Equal(t, 0, version)
|
||||
|
||||
var parents int
|
||||
require.NoError(t, conn.Get(&parents, `select count(*) from parents`))
|
||||
assert.Equal(t, 1, parents)
|
||||
|
||||
var children int
|
||||
require.NoError(t, conn.Get(&children, `select count(*) from children`))
|
||||
assert.Equal(t, 1, children)
|
||||
}
|
||||
|
||||
testcases2 := []db.Migration{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "syntax error",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
this is not valid sql;
|
||||
`,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Title: "unknown table",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
insert into no_such_table (rowid) values (1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Title: "constraint violation",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
insert into parents (rowid, name) values (1, 'duplicate rowid');
|
||||
`,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Title: "failure after a successful write",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
insert into parents (rowid, name) values (2, 'p2');
|
||||
insert into parents (rowid, name) values (1, 'duplicate rowid');
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases2 {
|
||||
t.Run(tc.Title, func(t *testing.T) {
|
||||
assert.Panics(t, func() { _ = tc.Apply(conn) })
|
||||
nothingHappens(t)
|
||||
assertForeignKeysAreGood(t, conn)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------
|
||||
// Foreign key failures should trigger a rollback too
|
||||
// ---------
|
||||
|
||||
testcases := []db.Migration{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "child pointing at a nonexistent parent",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
insert into children (rowid, parent_id) values (2, 404);
|
||||
`,
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Title: "parent deleted out from under an existing child",
|
||||
SQL: `
|
||||
create table new_table (rowid integer primary key);
|
||||
delete from parents where rowid = 1;
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.Title, func(t *testing.T) {
|
||||
// Should notify with the correct detail about the error
|
||||
func() {
|
||||
defer func() {
|
||||
err := recover()
|
||||
require.NotNil(t, err)
|
||||
switch err := err.(type) {
|
||||
case error:
|
||||
var violations db.PragmaFKViolations
|
||||
require.ErrorAs(t, err, &violations)
|
||||
require.Len(t, violations, 1)
|
||||
assert.Equal(t, "children", violations[0].SrcTable)
|
||||
assert.Equal(t, "parents", violations[0].DstTable)
|
||||
default:
|
||||
panic(err) // Something weird happened
|
||||
}
|
||||
}()
|
||||
_ = tc.Apply(conn)
|
||||
}()
|
||||
|
||||
// ...as well as the usual
|
||||
nothingHappens(t)
|
||||
assertForeignKeysAreGood(t, conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations are sequential: a migration may only be applied to a DB sitting at the immediately
|
||||
// preceding version.
|
||||
func TestMigrationApplyRejectsOutOfOrderMigrations(t *testing.T) {
|
||||
const sql = `insert into parents (name) values ('p2')`
|
||||
|
||||
t.Run("migration from the future", func(t *testing.T) {
|
||||
conn := newMigrationTestDB()
|
||||
|
||||
err := db.Migration{ID: 2, SQL: sql}.Apply(conn)
|
||||
require.ErrorIs(t, err, db.ErrMigrationOrder)
|
||||
|
||||
var version int
|
||||
require.NoError(t, conn.Get(&version, `select version from db_version`))
|
||||
assert.Equal(t, 0, version)
|
||||
|
||||
var parents int
|
||||
require.NoError(t, conn.Get(&parents, `select count(*) from parents`))
|
||||
assert.Equal(t, 1, parents, "the migration's SQL should not have run")
|
||||
|
||||
assertForeignKeysAreGood(t, conn)
|
||||
})
|
||||
|
||||
t.Run("migration applied twice", func(t *testing.T) {
|
||||
conn := newMigrationTestDB()
|
||||
m := db.Migration{ID: 1, SQL: sql}
|
||||
require.NoError(t, m.Apply(conn))
|
||||
|
||||
err := m.Apply(conn)
|
||||
require.ErrorIs(t, err, db.ErrMigrationOrder)
|
||||
|
||||
var version int
|
||||
require.NoError(t, conn.Get(&version, `select version from db_version`))
|
||||
assert.Equal(t, 1, version)
|
||||
|
||||
var parents int
|
||||
require.NoError(t, conn.Get(&parents, `select count(*) from parents`))
|
||||
assert.Equal(t, 2, parents, "the migration's SQL should not have run again")
|
||||
|
||||
assertForeignKeysAreGood(t, conn)
|
||||
})
|
||||
}
|
||||
42
pkg/db/pragma_fk_check.go
Normal file
42
pkg/db/pragma_fk_check.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
||||
)
|
||||
|
||||
// PragmaFKViolation is a result row of SQLite's `pragma foreign_key_check`.
|
||||
type PragmaFKViolation struct {
|
||||
SrcTable string `db:"table"`
|
||||
SrcRowid int `db:"rowid"`
|
||||
DstTable string `db:"parent"`
|
||||
ForeignKeyIdx int `db:"fkid"`
|
||||
}
|
||||
|
||||
// PragmaFKViolations is a slice of FK violations. It also implements the `error` interface.
|
||||
type PragmaFKViolations []PragmaFKViolation
|
||||
|
||||
func (v PragmaFKViolations) Error() string {
|
||||
var b strings.Builder
|
||||
for i, fkv := range v {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Item #%d in table %q has invalid reference to table %q", fkv.SrcRowid, fkv.SrcTable, fkv.DstTable)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PragmaForeignKeyCheck uses `pragma foreign_key_check` to do a full database check for any FK violations.
|
||||
func PragmaForeignKeyCheck(q sqlx.Queryer) error {
|
||||
var ret PragmaFKViolations
|
||||
must.Do(sqlx.Select(q, &ret, `pragma foreign_key_check`))
|
||||
if len(ret) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ret
|
||||
}
|
||||
51
pkg/db/vsc.go
Normal file
51
pkg/db/vsc.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const separator = "\x1f"
|
||||
|
||||
type VSC []string
|
||||
|
||||
// MarshalJSON replaces `nil` with `[]` for hygiene.
|
||||
func (vs VSC) MarshalJSON() ([]byte, error) {
|
||||
if vs == nil {
|
||||
vs = VSC{}
|
||||
}
|
||||
return json.Marshal([]string(vs)) //nolint: wrapcheck // Passthrough
|
||||
}
|
||||
|
||||
func (vs VSC) Value() (driver.Value, error) {
|
||||
fields := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
fields[i] = strings.ReplaceAll(v, separator, "")
|
||||
}
|
||||
return strings.Join(fields, separator), nil
|
||||
}
|
||||
|
||||
var ErrWrongVSCType = errors.New("incompatible DB type for VSC")
|
||||
|
||||
func (vs *VSC) Scan(src any) error {
|
||||
var s string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
case nil:
|
||||
*vs = nil
|
||||
return nil
|
||||
default:
|
||||
return ErrWrongVSCType
|
||||
}
|
||||
if s == "" {
|
||||
*vs = VSC{}
|
||||
return nil
|
||||
}
|
||||
*vs = strings.Split(s, separator)
|
||||
return nil
|
||||
}
|
||||
48
pkg/db/vsc_test.go
Normal file
48
pkg/db/vsc_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
|
||||
)
|
||||
|
||||
func TestVSCDBRoundTrip(t *testing.T) {
|
||||
conn, err := sqlx.Open("sqlite3", ":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||
|
||||
_, err = conn.Exec(`create table vsc_items (id integer primary key, items text not null)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
type vscRow struct {
|
||||
ID int `db:"id"`
|
||||
Items db.VSC `db:"items"`
|
||||
}
|
||||
|
||||
for _, want := range []vscRow{
|
||||
{ID: 1, Items: db.VSC{"email"}},
|
||||
{ID: 2, Items: db.VSC{"email", "profile"}},
|
||||
{ID: 3, Items: db.VSC{}},
|
||||
} {
|
||||
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got vscRow
|
||||
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
t.Run("nil gets upgrade to empty list", func(t *testing.T) {
|
||||
want := vscRow{ID: 4, Items: nil}
|
||||
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got vscRow
|
||||
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||
require.NotNil(t, got.Items)
|
||||
require.Equal(t, db.VSC{}, got.Items)
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
76
pkg/generic/mutexmap.go
Normal file
76
pkg/generic/mutexmap.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package generic
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MutexMap[K comparable, V any] struct {
|
||||
mu sync.RWMutex
|
||||
contents map[K]V
|
||||
}
|
||||
|
||||
func NewMutexMap[K comparable, V any]() *MutexMap[K, V] {
|
||||
return &MutexMap[K, V]{contents: make(map[K]V)}
|
||||
}
|
||||
|
||||
func (m *MutexMap[K, V]) Set(key K, value V) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.contents[key] = value
|
||||
}
|
||||
|
||||
func (m *MutexMap[K, V]) Get(key K) (V, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
v, ok := m.contents[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *MutexMap[K, V]) Len() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.contents)
|
||||
}
|
||||
|
||||
// Upd atomically updates the value for key. `fn` is used to determine how to update an existing
|
||||
// value, if there is one. It will be passed the existing value, and should return `(newValue V, shouldReplace bool)`.
|
||||
//
|
||||
// Returns the value that ends up associated with key after the call and whether an update occurred.
|
||||
func (m *MutexMap[K, V]) Upd(key K, fn func(prev V) (next V, shouldReplace bool)) (V, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
prev := m.contents[key]
|
||||
if next, shouldReplace := fn(prev); shouldReplace {
|
||||
m.contents[key] = next
|
||||
return next, true
|
||||
}
|
||||
return prev, false
|
||||
}
|
||||
|
||||
// Del removes key from the map. Returns true if the key was present.
|
||||
func (m *MutexMap[K, V]) Del(key K) bool { //nolint: boolnames // Return value is incidental, not the main purpose of the function
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.contents[key]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(m.contents, key)
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MutexMap[K, V]) Cpy() map[K]V {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
cp := make(map[K]V, len(m.contents))
|
||||
maps.Copy(cp, m.contents)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (m *MutexMap[K, V]) Pop(key K) (ret V, isOk bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ret, isOk = m.contents[key]
|
||||
delete(m.contents, key)
|
||||
return
|
||||
}
|
||||
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())
|
||||
}
|
||||
61
pkg/schema/column.go
Normal file
61
pkg/schema/column.go
Normal file
@@ -0,0 +1,61 @@
|
||||
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"`
|
||||
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 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())
|
||||
}
|
||||
19
pkg/schema/index.go
Normal file
19
pkg/schema/index.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package schema
|
||||
|
||||
type IndexOrigin string
|
||||
|
||||
const (
|
||||
IndexOriginCreateIndex = IndexOrigin("c")
|
||||
IndexOriginUniqueConstraint = IndexOrigin("u")
|
||||
IndexOriginPrimaryKey = IndexOrigin("pk")
|
||||
)
|
||||
|
||||
type Index struct {
|
||||
Name string `db:"index_name"`
|
||||
TableName string `db:"table_name"`
|
||||
Columns []string
|
||||
IsUnique bool `db:"is_unique"`
|
||||
Origin IndexOrigin `db:"origin"`
|
||||
// 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"
|
||||
)
|
||||
|
||||
@@ -50,14 +50,14 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
db1Schema := schema.SchemaFromDB(db1)
|
||||
|
||||
t.Run("migrate in 1 step", func(t *testing.T) {
|
||||
migration := `
|
||||
migration := db.Migration{ID: 1, SQL: `
|
||||
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:"))
|
||||
`}
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{migration})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -67,17 +67,17 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("migrate in 2 steps", func(t *testing.T) {
|
||||
migration1 := `
|
||||
migration1 := db.Migration{ID: 1, SQL: `
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
`
|
||||
migration2 := `
|
||||
`}
|
||||
migration2 := db.Migration{ID: 2, SQL: `
|
||||
alter table t1 add column data3 integer;
|
||||
`
|
||||
`}
|
||||
|
||||
db2Config := db.Init(&baseSchema, &[]string{migration1, migration2})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{migration1, migration2})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -92,8 +92,8 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
db1Schema := schema.SchemaFromDB(db1)
|
||||
|
||||
t.Run("missing migration", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]string{})
|
||||
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -109,12 +109,14 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("incomplete migration", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]string{`
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
||||
{ID: 1, SQL: `
|
||||
create table t2 (
|
||||
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)
|
||||
|
||||
@@ -130,13 +132,15 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("incorrect migration (wrong data type)", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]string{`
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
||||
{ID: 1, SQL: `
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
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, origin 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
|
||||
}
|
||||
@@ -2,84 +2,8 @@ package schema
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"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"`
|
||||
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 +62,35 @@ 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
|
||||
// HasSoftDelete reports whether this table supports soft-deletion, i.e., whether it has both
|
||||
// an "is_deleted" and a "deleted_at" column.
|
||||
func (t Table) HasSoftDelete() bool {
|
||||
var hasIsDeleted, hasDeletedAt bool
|
||||
for _, c := range t.Columns {
|
||||
if c.Name == "is_deleted" && c.Type == "integer" {
|
||||
hasIsDeleted = true
|
||||
}
|
||||
if c.Name == "deleted_at" && c.Type == "integer" {
|
||||
hasDeletedAt = true
|
||||
}
|
||||
}
|
||||
return hasIsDeleted && hasDeletedAt
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
Tables map[string]Table
|
||||
Indexes map[string]Index
|
||||
// GetUpdateableColumns returns the columns that should be written to by an "update" operation.
|
||||
func (t Table) GetUpdateableColumns() (ret []Column) {
|
||||
hasCreatedAt, _ := t.HasAutoTimestamps()
|
||||
for _, col := range t.Columns {
|
||||
if col.Name == "rowid" || col.IsPrimaryKey {
|
||||
continue // Rowid / primary key is never updateable
|
||||
}
|
||||
if col.Name == "created_at" && hasCreatedAt {
|
||||
continue // Update shouldn't write `created_at`
|
||||
}
|
||||
if t.HasSoftDelete() && (col.Name == "is_deleted" || col.Name == "deleted_at") {
|
||||
continue // Soft-delete state is managed by SoftDeleteXyz(), not by updates
|
||||
}
|
||||
ret = append(ret, col)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
34
pkg/schema/table_test.go
Normal file
34
pkg/schema/table_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package schema
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTableGetUpdateableColumns(t *testing.T) {
|
||||
tbl := Table{
|
||||
Columns: []Column{
|
||||
{Name: "rowid"},
|
||||
{Name: "version", Type: "integer", IsPrimaryKey: true},
|
||||
{Name: "created_at", Type: "integer"},
|
||||
{Name: "updated_at", Type: "integer"},
|
||||
{Name: "is_deleted", Type: "integer"},
|
||||
{Name: "deleted_at", Type: "integer"},
|
||||
{Name: "owner_id", Type: "integer", IsForeignKey: true},
|
||||
{Name: "name", Type: "text"},
|
||||
},
|
||||
}
|
||||
|
||||
gotColumns := tbl.GetUpdateableColumns()
|
||||
got := make([]string, 0, len(gotColumns))
|
||||
for _, col := range gotColumns {
|
||||
got = append(got, col.Name)
|
||||
}
|
||||
|
||||
want := []string{"updated_at", "owner_id", "name"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ create temporary view columns as
|
||||
create temporary view indexes as
|
||||
select idx.name as index_name,
|
||||
tables.name as table_name,
|
||||
idx."unique" as is_unique
|
||||
idx."unique" as is_unique,
|
||||
idx.origin as origin
|
||||
from tables
|
||||
join pragma_index_list(tables.name) idx;
|
||||
|
||||
|
||||
@@ -9,3 +9,7 @@ func SnakeToCamel(s string) string {
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func CamelToPascal(s string) string {
|
||||
return strings.ToLower(s)[0:1] + s[1:]
|
||||
}
|
||||
|
||||
15
pkg/textutils/terminal_colors.go
Normal file
15
pkg/textutils/terminal_colors.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package textutils
|
||||
|
||||
// Colors for terminal output
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorBlack = "\033[30m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorPurple = "\033[35m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[37m"
|
||||
ColorWhite = "\033[97m"
|
||||
)
|
||||
55
pkg/version/version.go
Normal file
55
pkg/version/version.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Version is stamped in at compile time by `ops/compile.sh`, via:
|
||||
//
|
||||
// -ldflags "-X git.offline-twitter.com/offline-labs/gas-stack/pkg/version.Version=v0.0.4"
|
||||
//
|
||||
// It's empty for builds that don't go through that script-- most importantly
|
||||
// `go install git.offline-twitter.com/offline-labs/gas-stack/cmd@v0.0.4`, which is the normal way
|
||||
// to install `gas`. In that case, String() recovers the version from the build info that the Go
|
||||
// toolchain embeds in every binary.
|
||||
var Version = ""
|
||||
|
||||
// String returns the version of this binary, e.g. "v0.0.4".
|
||||
func String() string {
|
||||
if Version != "" {
|
||||
return Version
|
||||
}
|
||||
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Set by `go install module@version`, and (as of Go 1.24) by `go build` in a git checkout
|
||||
// that has the tags available. It's "(devel)" if the toolchain couldn't work out a version.
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
return info.Main.Version
|
||||
}
|
||||
|
||||
// No module version, so fall back to the commit. This happens when building from a source
|
||||
// tree with no usable VCS metadata: a git worktree, a `.git`-less export, or `-buildvcs=false`.
|
||||
var revision, dirty string
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
revision = setting.Value
|
||||
case "vcs.modified":
|
||||
if setting.Value == "true" {
|
||||
dirty = "-dirty"
|
||||
}
|
||||
}
|
||||
}
|
||||
if revision == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(revision) > 12 {
|
||||
revision = revision[:12]
|
||||
}
|
||||
return fmt.Sprintf("devel-%s%s", revision, dirty)
|
||||
}
|
||||
Reference in New Issue
Block a user