Compare commits
1 Commits
f98f8416e8
...
wispem/cod
| Author | SHA1 | Date | |
|---|---|---|---|
| 83a7087a3b |
@@ -5,8 +5,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,16 +16,13 @@ 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)
|
||||
root_cmd.AddCommand(generate_codetable_type)
|
||||
if err := root_cmd.Execute(); err != nil {
|
||||
fmt.Println(RED + err.Error() + RESET)
|
||||
os.Exit(1)
|
||||
|
||||
55
cmd/subcmd_generate_codetable_type.go
Normal file
55
cmd/subcmd_generate_codetable_type.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"os"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var generate_codetable_type = &cobra.Command{
|
||||
Use: "generate_codetable <table_name>",
|
||||
Short: "Generate a code-table enum type",
|
||||
|
||||
Args: cobra.ExactArgs(1),
|
||||
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path := Must(cmd.Flags().GetString("schema"))
|
||||
sql, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading path %s: %w", path, err)
|
||||
}
|
||||
db := schema.InitDB(string(sql))
|
||||
schema := schema.SchemaFromDB(db)
|
||||
table, isOk := schema.Tables[args[0]]
|
||||
if !isOk {
|
||||
return ErrNoSuchTable
|
||||
}
|
||||
|
||||
vals := table.GetCodeTableValues(db)
|
||||
decls := []ast.Decl{
|
||||
modelgenerate.GenerateCodetableType(table),
|
||||
modelgenerate.GenerateCodetableEnum(table, vals),
|
||||
modelgenerate.GenerateCodetableStringerFunc(table, vals),
|
||||
}
|
||||
|
||||
file := &ast.File{
|
||||
Name: ast.NewIdent("db"), // TODO: parameterize
|
||||
Decls: decls,
|
||||
}
|
||||
|
||||
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DUPE: generate-flags
|
||||
func init() {
|
||||
generate_codetable_type.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||
generate_codetable_type.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||
generate_codetable_type.Flags().Bool("test", false, "Generate test file instead of regular file")
|
||||
}
|
||||
@@ -69,9 +69,6 @@ var generate_model = &cobra.Command{
|
||||
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()),
|
||||
@@ -90,10 +87,6 @@ var generate_model = &cobra.Command{
|
||||
// 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)
|
||||
@@ -118,6 +111,7 @@ var generate_model = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// DUPE: generate-flags
|
||||
func init() {
|
||||
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||
generate_model.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,6 +1,13 @@
|
||||
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)
|
||||
@@ -15,26 +22,16 @@ 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"
|
||||
- Example: `chat_pinned_messages`
|
||||
|
||||
TODO: create a Getter function for each unique index
|
||||
|
||||
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,12 +29,6 @@
|
||||
|
||||
- `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,3 +1 @@
|
||||
https://astexplorer.net/
|
||||
https://github.com/dave/dst
|
||||
https://disaev.me/p/writing-useful-go-analysis-linter/
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 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
|
||||
go build -tags fts5 -o gas ./cmd
|
||||
|
||||
@@ -10,5 +10,4 @@ sudo docker run --rm -it \
|
||||
-v /memory:/memory \
|
||||
--workdir /code \
|
||||
--net host \
|
||||
--name gas \
|
||||
gas
|
||||
|
||||
0
ops/test.sh
Executable file → Normal file
0
ops/test.sh
Executable file → Normal file
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package modelgenerate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
func GenerateCodetableType(table schema.Table) *ast.GenDecl {
|
||||
return &ast.GenDecl{
|
||||
Tok: token.TYPE,
|
||||
Specs: []ast.Spec{&ast.TypeSpec{Name: &ast.Ident{Name: table.GoTypeName}, Type: &ast.Ident{Name: "int"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateCodetableEnum(table schema.Table, vals []string) *ast.GenDecl {
|
||||
getConstName := func(s string) string {
|
||||
return table.GoTypeName + textutils.KebabToPascal(s)
|
||||
}
|
||||
|
||||
constSpecs := []ast.Spec{}
|
||||
for i, val := range vals {
|
||||
spec := &ast.ValueSpec{
|
||||
Names: []*ast.Ident{{Name: getConstName(val)}},
|
||||
}
|
||||
// Only the first one needs `iota`
|
||||
if i == 0 {
|
||||
spec.Type = &ast.Ident{Name: table.GoTypeName}
|
||||
spec.Values = []ast.Expr{&ast.BinaryExpr{
|
||||
X: &ast.Ident{Name: "iota"},
|
||||
Op: token.ADD,
|
||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||
}}
|
||||
}
|
||||
constSpecs = append(constSpecs, spec)
|
||||
}
|
||||
|
||||
return &ast.GenDecl{Tok: token.CONST, Specs: constSpecs}
|
||||
}
|
||||
|
||||
// GenerateCodetableStringerFunc implements the `Stringer` interface by defining a `String() string` function.
|
||||
func GenerateCodetableStringerFunc(table schema.Table, vals []string) *ast.FuncDecl {
|
||||
objIdent := ast.NewIdent(table.VarName)
|
||||
return &ast.FuncDecl{
|
||||
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{objIdent}, Type: &ast.Ident{Name: table.GoTypeName}}}},
|
||||
Name: &ast.Ident{Name: "String"},
|
||||
Type: &ast.FuncType{Params: &ast.FieldList{}, Results: &ast.FieldList{List: []*ast.Field{{Type: &ast.Ident{Name: "string"}}}}},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
// names := []string{ ... }
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{&ast.Ident{Name: "names"}},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{
|
||||
&ast.CompositeLit{
|
||||
Type: &ast.ArrayType{Elt: &ast.Ident{Name: "string"}},
|
||||
Elts: func() []ast.Expr {
|
||||
ret := []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `""`},
|
||||
}
|
||||
for _, val := range vals {
|
||||
ret = append(ret, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", val)})
|
||||
}
|
||||
return ret
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// if int(c) < 1 || int(c) >= len(names) { return "invalid" }
|
||||
&ast.IfStmt{
|
||||
Cond: &ast.BinaryExpr{
|
||||
X: &ast.BinaryExpr{
|
||||
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||
Op: token.LSS,
|
||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||
},
|
||||
Op: token.LOR,
|
||||
Y: &ast.BinaryExpr{
|
||||
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||
Op: token.GEQ,
|
||||
Y: &ast.CallExpr{Fun: &ast.Ident{Name: "len"}, Args: []ast.Expr{&ast.Ident{Name: "names"}}},
|
||||
},
|
||||
},
|
||||
Body: &ast.BlockStmt{
|
||||
List: []ast.Stmt{
|
||||
&ast.ReturnStmt{Results: []ast.Expr{
|
||||
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")}, Args: []ast.Expr{
|
||||
&ast.BasicLit{Kind: token.STRING, Value: `"<%d=invalid>"`},
|
||||
objIdent,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
// return names[c]
|
||||
&ast.ReturnStmt{
|
||||
Results: []ast.Expr{
|
||||
&ast.IndexExpr{
|
||||
X: &ast.Ident{Name: "names"},
|
||||
Index: objIdent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,6 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
updatePairs := make([]string, 0, len(tbl.Columns))
|
||||
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
hasSoftDelete := tbl.HasSoftDelete()
|
||||
|
||||
// Assemble data for building SQL "insert" and "update" strings
|
||||
for _, col := range tbl.Columns {
|
||||
@@ -313,10 +312,6 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
if col.Name == "created_at" && hasCreatedAt {
|
||||
continue
|
||||
}
|
||||
// Soft-delete state is managed by SoftDeleteXyz(), not by updates
|
||||
if hasSoftDelete && (col.Name == "is_deleted" || col.Name == "deleted_at") {
|
||||
continue
|
||||
}
|
||||
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
|
||||
updatePairs = append(updatePairs, col.Name+"="+val)
|
||||
}
|
||||
@@ -520,16 +515,11 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
}(),
|
||||
}
|
||||
|
||||
docLines := []*ast.Comment{
|
||||
{Text: fmt.Sprintf("// Save%s creates or updates a %s in the database.", tbl.GoTypeName, tbl.GoTypeName)},
|
||||
}
|
||||
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},
|
||||
Doc: &ast.CommentGroup{List: []*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."},
|
||||
}},
|
||||
Recv: dbRecv,
|
||||
Name: ast.NewIdent("Save" + tbl.GoTypeName),
|
||||
Type: &ast.FuncType{
|
||||
@@ -833,66 +823,6 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
|
||||
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))
|
||||
|
||||
@@ -69,11 +69,9 @@ func SampleValue(c pkgschema.Column, offset int) ast.Expr {
|
||||
// UpdateTestFields returns the columns for which the test generator should assign its own
|
||||
// sample values, in the MakeXyz() factory and in the create/update test: the same columns
|
||||
// that GenerateSaveItemFunc's "update" branch writes to, minus foreign keys (which can't be
|
||||
// given plausible values here), the auto-managed "created_at"/"updated_at" columns, and the
|
||||
// soft-delete columns (which SoftDeleteXyz() manages).
|
||||
// given plausible values here) and the auto-managed "created_at"/"updated_at" columns.
|
||||
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||
hasSoftDelete := tbl.HasSoftDelete()
|
||||
for _, c := range tbl.Columns {
|
||||
if c.Name == "rowid" || c.IsPrimaryKey || c.IsForeignKey {
|
||||
continue
|
||||
@@ -84,9 +82,6 @@ func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
|
||||
if c.Name == "updated_at" && hasUpdatedAt {
|
||||
continue
|
||||
}
|
||||
if hasSoftDelete && (c.Name == "is_deleted" || c.Name == "deleted_at") {
|
||||
continue
|
||||
}
|
||||
ret = append(ret, c)
|
||||
}
|
||||
return
|
||||
@@ -101,55 +96,12 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
|
||||
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: makeHelperName,
|
||||
Type: &ast.FuncType{
|
||||
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
|
||||
}()},
|
||||
Params: &ast.FieldList{},
|
||||
Results: &ast.FieldList{
|
||||
List: []*ast.Field{
|
||||
{Type: ast.NewIdent(tbl.GoTypeName)},
|
||||
@@ -157,62 +109,16 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
},
|
||||
},
|
||||
Body: &ast.BlockStmt{
|
||||
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{
|
||||
List: []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,
|
||||
Value: SampleValue(c, 0),
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -220,8 +126,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
},
|
||||
},
|
||||
},
|
||||
}...,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -333,7 +238,6 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
Rhs: []ast.Expr{
|
||||
&ast.CallExpr{
|
||||
Fun: ast.NewIdent("Make" + tbl.GoTypeName),
|
||||
Args: makeItemArgs,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -420,7 +324,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
&ast.AssignStmt{
|
||||
Lhs: []ast.Expr{testObj},
|
||||
Tok: token.DEFINE,
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: makeItemArgs}},
|
||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}},
|
||||
},
|
||||
// item.Description = <sample value, offset 1>
|
||||
// ...one assignment per updatable field
|
||||
@@ -569,8 +473,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
ast.NewIdent("t"),
|
||||
&ast.CallExpr{
|
||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
||||
"Get" + inflection.Plural(pkgschema.TypenameFromTablename(tbl.TableName)) +
|
||||
"By" + funcNameSuffix,
|
||||
"Get" + inflection.Plural(pkgschema.TypenameFromTablename(tbl.TableName)) + "By" + funcNameSuffix,
|
||||
)},
|
||||
Args: callArgs,
|
||||
},
|
||||
@@ -651,15 +554,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
Decls: append([]ast.Decl{
|
||||
&ast.GenDecl{
|
||||
Tok: token.IMPORT,
|
||||
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{
|
||||
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"`},
|
||||
@@ -675,9 +570,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
||||
&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...),
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
var sql_schema string
|
||||
|
||||
// Database starts at version 0. First migration brings us to version 1
|
||||
var MIGRATIONS = []db.Migration{}
|
||||
var MIGRATIONS = []string{}
|
||||
|
||||
type DB struct {
|
||||
DB *sqlx.DB
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
type DBConfig struct {
|
||||
@@ -20,11 +18,25 @@ type DBConfig struct {
|
||||
sql_schema *string
|
||||
|
||||
// Database starts at version 0. First migration brings us to version 1
|
||||
migrations *[]Migration
|
||||
migrations *[]string
|
||||
version_number uint
|
||||
}
|
||||
|
||||
func Init(schema *string, migrationsList *[]Migration) DBConfig {
|
||||
// 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 {
|
||||
return DBConfig{
|
||||
sql_schema: schema,
|
||||
migrations: migrationsList,
|
||||
@@ -84,32 +96,45 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
|
||||
}
|
||||
|
||||
if c.version_number > version {
|
||||
fmt.Print(textutils.ColorYellow)
|
||||
fmt.Print(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(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)
|
||||
fmt.Print(ColorReset)
|
||||
c.UpgradeFromXToY(db, version, c.version_number)
|
||||
}
|
||||
|
||||
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 := []db.Migration{}
|
||||
migrations := []string{}
|
||||
|
||||
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 := []db.Migration{}
|
||||
migrations := []string{}
|
||||
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, 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))
|
||||
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)))
|
||||
|
||||
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, db.Migration{ID: 2, SQL: `alter table items add column name string default 'asdf'`})
|
||||
migrations = append(migrations, `alter table items add column name string default 'asdf'`)
|
||||
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
require.NoError(config.CheckAndUpdateVersion(connection))
|
||||
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
|
||||
var items3 []struct {
|
||||
ID uint64 `db:"rowid"`
|
||||
|
||||
@@ -12,8 +12,6 @@ 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 {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -8,9 +8,15 @@ import (
|
||||
|
||||
// Column represents a single column in a table.
|
||||
type Column struct {
|
||||
// TableName is the name of the SQLite table this column belongs to.
|
||||
TableName string `db:"table_name"`
|
||||
|
||||
// Name is the SQLite column name.
|
||||
Name string `db:"column_name"`
|
||||
|
||||
// Type is the SQLite type this column contains.
|
||||
Type string `db:"column_type"`
|
||||
|
||||
IsNotNull bool `db:"notnull"`
|
||||
HasDefaultValue bool `db:"has_default_value"`
|
||||
DefaultValue string `db:"dflt_value"`
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
db1Schema := schema.SchemaFromDB(db1)
|
||||
|
||||
t.Run("migrate in 1 step", func(t *testing.T) {
|
||||
migration := db.Migration{ID: 1, SQL: `
|
||||
migration := `
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
alter table t1 add column data3 integer;
|
||||
`}
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{migration})
|
||||
`
|
||||
db2Config := db.Init(&baseSchema, &[]string{migration})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
@@ -67,16 +67,16 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("migrate in 2 steps", func(t *testing.T) {
|
||||
migration1 := db.Migration{ID: 1, SQL: `
|
||||
migration1 := `
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
`}
|
||||
migration2 := db.Migration{ID: 2, SQL: `
|
||||
`
|
||||
migration2 := `
|
||||
alter table t1 add column data3 integer;
|
||||
`}
|
||||
`
|
||||
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{migration1, migration2})
|
||||
db2Config := db.Init(&baseSchema, &[]string{migration1, migration2})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
@@ -92,7 +92,7 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
db1Schema := schema.SchemaFromDB(db1)
|
||||
|
||||
t.Run("missing migration", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{})
|
||||
db2Config := db.Init(&baseSchema, &[]string{})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
@@ -109,13 +109,11 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("incomplete migration", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
||||
{ID: 1, SQL: `
|
||||
db2Config := db.Init(&baseSchema, &[]string{`
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
`},
|
||||
})
|
||||
`})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
@@ -132,14 +130,12 @@ func TestIncorrectMigrations(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("incorrect migration (wrong data type)", func(t *testing.T) {
|
||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
||||
{ID: 1, SQL: `
|
||||
db2Config := db.Init(&baseSchema, &[]string{`
|
||||
create table t2 (
|
||||
rowid integer primary key
|
||||
);
|
||||
alter table t1 add column data3 text;
|
||||
`},
|
||||
})
|
||||
`})
|
||||
db2 := must.Get(db2Config.Create(":memory:"))
|
||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||
db2Schema := schema.SchemaFromDB(db2)
|
||||
|
||||
@@ -49,7 +49,7 @@ func SchemaFromDB(db *sqlx.DB) Schema {
|
||||
}
|
||||
|
||||
var indexes []Index
|
||||
must.Do(db.Select(&indexes, `select index_name, table_name, is_unique, origin from indexes`))
|
||||
must.Do(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
|
||||
for _, idx := range indexes {
|
||||
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
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Table is a single SQLite table.
|
||||
@@ -62,17 +67,10 @@ func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
func (t Table) GetCodeTableValues(db *sqlx.DB) (ret []string) {
|
||||
if !slices.ContainsFunc(t.Columns, func(c Column) bool { return c.Name == "name" }) {
|
||||
panic("not a code table")
|
||||
}
|
||||
if c.Name == "deleted_at" && c.Type == "integer" {
|
||||
hasDeletedAt = true
|
||||
}
|
||||
}
|
||||
return hasIsDeleted && hasDeletedAt
|
||||
flowutils.PanicIf(db.Select(&ret, fmt.Sprintf("select name from %s", t.TableName)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ 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.origin as origin
|
||||
idx."unique" as is_unique
|
||||
from tables
|
||||
join pragma_index_list(tables.name) idx;
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ func SnakeToCamel(s string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func KebabToPascal(s string) string {
|
||||
parts := strings.Split(s, "-")
|
||||
for i, part := range parts {
|
||||
if len(part) > 0 {
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func CamelToPascal(s string) string {
|
||||
return strings.ToLower(s)[0:1] + s[1:]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
8
sample_data/test_schemas/codetables.sql
Normal file
8
sample_data/test_schemas/codetables.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
create table item_types (
|
||||
rowid integer primary key,
|
||||
name text not null unique
|
||||
) strict;
|
||||
insert into item_types(rowid, name) values
|
||||
(1, 'first-type'),
|
||||
(2, 'second-type'),
|
||||
(3, 'third-type');
|
||||
Reference in New Issue
Block a user