Compare commits
2 Commits
master
...
01e37a0e07
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e37a0e07 | |||
| f8664ed514 |
@@ -1,10 +0,0 @@
|
|||||||
# Builds a golangci-lint binary with the shared custom linters from go-lint.
|
|
||||||
#
|
|
||||||
# The version MUST stay in lockstep with the installed golangci-lint version:
|
|
||||||
# plugins link against golangci-lint internals.
|
|
||||||
version: v2.13.1
|
|
||||||
name: golint
|
|
||||||
destination: ./bin
|
|
||||||
plugins:
|
|
||||||
- module: git.offline-twitter.com/offline-labs/go-lint
|
|
||||||
version: v0.0.0-20261018231414-f5b6076dce76
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on: [push, workflow_dispatch]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# These steps build the `gas` docker image.
|
# These steps build the `gas` docker image.
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,4 +4,3 @@ sample_data/data
|
|||||||
.cmd-old/
|
.cmd-old/
|
||||||
pkg/.testapp
|
pkg/.testapp
|
||||||
.claude
|
.claude
|
||||||
bin
|
|
||||||
|
|||||||
@@ -20,20 +20,7 @@ linters:
|
|||||||
- whitespace
|
- whitespace
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
|
|
||||||
# Shared custom linters from ../go-lint.
|
|
||||||
- ifinit
|
|
||||||
- boolnames
|
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
custom:
|
|
||||||
ifinit:
|
|
||||||
type: module
|
|
||||||
description: if-statement init clauses must be on a single line
|
|
||||||
boolnames:
|
|
||||||
type: module
|
|
||||||
description: boolean variables and functions should use is, has, was, and similar prefixes
|
|
||||||
settings:
|
|
||||||
allowed_prefixes: ["is", "has", "can", "was", "should"]
|
|
||||||
depguard:
|
depguard:
|
||||||
rules:
|
rules:
|
||||||
main:
|
main:
|
||||||
@@ -67,16 +54,17 @@ linters:
|
|||||||
require-specific: true
|
require-specific: true
|
||||||
allow-unused: false
|
allow-unused: false
|
||||||
staticcheck:
|
staticcheck:
|
||||||
|
go: "1.24"
|
||||||
checks:
|
checks:
|
||||||
- all
|
- all
|
||||||
- -ST1000 # Re-enable this once we have docstrings
|
- -ST1000 # Re-enable this once we have docstrings
|
||||||
|
- -ST1003 # I like snake_case
|
||||||
- -ST1013 # HTTP status codes are shorter and more readable than names
|
- -ST1013 # HTTP status codes are shorter and more readable than names
|
||||||
wrapcheck:
|
dot-import-whitelist:
|
||||||
report-internal-errors: false
|
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
extra-ignore-sigs:
|
|
||||||
- json.Marshal(
|
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax # Don't lint generated files
|
generated: lax # Don't lint generated files
|
||||||
|
paths:
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
@@ -92,6 +80,7 @@ formatters:
|
|||||||
simplify: true
|
simplify: true
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
paths:
|
||||||
|
|
||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
|
|||||||
17
cmd/main.go
17
cmd/main.go
@@ -5,8 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -16,19 +14,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
rootCmd := &cobra.Command{
|
root_cmd := &cobra.Command{
|
||||||
Use: "gas",
|
Use: "gas",
|
||||||
Version: version.String(),
|
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
}
|
}
|
||||||
// Print a bare version string ("v0.0.4"), rather than cobra's "gas version v0.0.4".
|
root_cmd.AddCommand(sqlite_lint)
|
||||||
rootCmd.SetVersionTemplate("{{.Version}}\n")
|
root_cmd.AddCommand(cmd_init)
|
||||||
rootCmd.AddCommand(sqliteLint)
|
root_cmd.AddCommand(generate_model)
|
||||||
rootCmd.AddCommand(cmdInit)
|
root_cmd.AddCommand(generate_codetable_type)
|
||||||
rootCmd.AddCommand(generateModel)
|
if err := root_cmd.Execute(); err != nil {
|
||||||
rootCmd.AddCommand(cmdVersion)
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
|
||||||
fmt.Println(RED + err.Error() + RESET)
|
fmt.Println(RED + err.Error() + RESET)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
55
cmd/subcmd_generate_codetable_type.go
Normal file
55
cmd/subcmd_generate_codetable_type.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
||||||
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var generate_codetable_type = &cobra.Command{
|
||||||
|
Use: "generate_codetable <table_name>",
|
||||||
|
Short: "Generate a code-table enum type",
|
||||||
|
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
path := Must(cmd.Flags().GetString("schema"))
|
||||||
|
sql, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
db := schema.InitDB(string(sql))
|
||||||
|
schema := schema.SchemaFromDB(db)
|
||||||
|
table, isOk := schema.Tables[args[0]]
|
||||||
|
if !isOk {
|
||||||
|
return ErrNoSuchTable
|
||||||
|
}
|
||||||
|
|
||||||
|
vals := table.GetCodeTableValues(db)
|
||||||
|
decls := []ast.Decl{
|
||||||
|
modelgenerate.GenerateCodetableType(table),
|
||||||
|
modelgenerate.GenerateCodetableEnum(table, vals),
|
||||||
|
modelgenerate.GenerateCodetableStringerFunc(table, vals),
|
||||||
|
}
|
||||||
|
|
||||||
|
file := &ast.File{
|
||||||
|
Name: ast.NewIdent("db"), // TODO: parameterize
|
||||||
|
Decls: decls,
|
||||||
|
}
|
||||||
|
|
||||||
|
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DUPE: generate-flags
|
||||||
|
func init() {
|
||||||
|
generate_codetable_type.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||||
|
generate_codetable_type.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||||
|
generate_codetable_type.Flags().Bool("test", false, "Generate test file instead of regular file")
|
||||||
|
}
|
||||||
@@ -6,40 +6,39 @@ import (
|
|||||||
"go/ast"
|
"go/ast"
|
||||||
"go/token"
|
"go/token"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNoSuchTable = errors.New("no such table")
|
var ErrNoSuchTable = errors.New("no such table")
|
||||||
|
|
||||||
var generateModel = &cobra.Command{
|
var generate_model = &cobra.Command{
|
||||||
Use: "generate <model_name>",
|
Use: "generate <model_name>",
|
||||||
Short: "Generate a model type",
|
Short: "Generate a model type",
|
||||||
|
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
path := must.Get(cmd.Flags().GetString("schema"))
|
path := Must(cmd.Flags().GetString("schema"))
|
||||||
modname := must.Get(cmd.Flags().GetString("modname"))
|
modname := Must(cmd.Flags().GetString("modname"))
|
||||||
sql, err := os.ReadFile(path)
|
sql, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading path %s: %w", path, err)
|
return fmt.Errorf("reading path %s: %w", path, err)
|
||||||
}
|
}
|
||||||
db := schema.InitDB(string(sql))
|
db := schema.InitDB(string(sql))
|
||||||
sch := schema.SchemaFromDB(db)
|
schema := schema.SchemaFromDB(db)
|
||||||
table, isOk := sch.Tables[args[0]]
|
table, isOk := schema.Tables[args[0]]
|
||||||
if !isOk {
|
if !isOk {
|
||||||
return ErrNoSuchTable
|
return ErrNoSuchTable
|
||||||
}
|
}
|
||||||
|
|
||||||
if must.Get(cmd.Flags().GetBool("test")) {
|
if Must(cmd.Flags().GetBool("test")) {
|
||||||
file2 := modelgenerate.GenerateModelTestAST(table, sch, modname)
|
file2 := modelgenerate.GenerateModelTestAST(table, schema, modname)
|
||||||
must.Do(modelgenerate.FprintWithComments(os.Stdout, file2))
|
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file2))
|
||||||
} else {
|
} else {
|
||||||
decls := []ast.Decl{
|
decls := []ast.Decl{
|
||||||
&ast.GenDecl{
|
&ast.GenDecl{
|
||||||
@@ -52,9 +51,10 @@ var generateModel = &cobra.Command{
|
|||||||
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
|
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
|
||||||
},
|
},
|
||||||
&ast.ImportSpec{
|
&ast.ImportSpec{
|
||||||
|
Name: ast.NewIdent("."),
|
||||||
Path: &ast.BasicLit{
|
Path: &ast.BasicLit{
|
||||||
Kind: token.STRING,
|
Kind: token.STRING,
|
||||||
Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`,
|
Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -69,9 +69,6 @@ var generateModel = &cobra.Command{
|
|||||||
modelgenerate.GenerateSaveItemFunc(table),
|
modelgenerate.GenerateSaveItemFunc(table),
|
||||||
modelgenerate.GenerateDeleteItemFunc(table),
|
modelgenerate.GenerateDeleteItemFunc(table),
|
||||||
)
|
)
|
||||||
if table.HasSoftDelete() {
|
|
||||||
decls = append(decls, modelgenerate.GenerateSoftDeleteItemFunc(table))
|
|
||||||
}
|
|
||||||
if table.IsWithoutRowid {
|
if table.IsWithoutRowid {
|
||||||
decls = append(decls,
|
decls = append(decls,
|
||||||
modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()),
|
modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()),
|
||||||
@@ -81,27 +78,13 @@ var generateModel = &cobra.Command{
|
|||||||
modelgenerate.GenerateGetItemByIDFunc(table),
|
modelgenerate.GenerateGetItemByIDFunc(table),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for _, index := range sch.Indexes {
|
for _, index := range schema.Indexes {
|
||||||
if index.TableName != table.TableName {
|
if index.TableName != table.TableName {
|
||||||
// Skip indexes on other tables
|
// Skip indexes on other tables
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(index.Columns, "") {
|
if index.IsUnique && len(index.Columns) == 1 {
|
||||||
// Skip expression indexes; there's no way to resolve an expression to a real column
|
decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
|
||||||
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))
|
decls = append(decls, modelgenerate.GenerateGetAllItemsFunc(table))
|
||||||
@@ -111,15 +94,16 @@ var generateModel = &cobra.Command{
|
|||||||
Decls: decls,
|
Decls: decls,
|
||||||
}
|
}
|
||||||
|
|
||||||
must.Do(modelgenerate.FprintWithComments(os.Stdout, file))
|
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DUPE: generate-flags
|
||||||
func init() {
|
func init() {
|
||||||
generateModel.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
|
||||||
generateModel.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
generate_model.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
|
||||||
generateModel.Flags().Bool("test", false, "Generate test file instead of regular file")
|
generate_model.Flags().Bool("test", false, "Generate test file instead of regular file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdInit = &cobra.Command{
|
var cmd_init = &cobra.Command{
|
||||||
Use: "init [path]",
|
Use: "init [path]",
|
||||||
Short: "Initialize a new project",
|
Short: "Initialize a new project",
|
||||||
Long: "Initialize a new Gas Stack project at the given path. If no path is given, defaults to current directory.",
|
Long: "Initialize a new Gas Stack project at the given path. If no path is given, defaults to current directory.",
|
||||||
@@ -23,15 +23,15 @@ var cmdInit = &cobra.Command{
|
|||||||
var target string
|
var target string
|
||||||
if len(args) != 0 {
|
if len(args) != 0 {
|
||||||
target = args[0]
|
target = args[0]
|
||||||
must.Do(os.MkdirAll(target, 0o755))
|
PanicIf(os.MkdirAll(target, 0o755))
|
||||||
must.Do(os.Chdir(target))
|
PanicIf(os.Chdir(target))
|
||||||
} else {
|
} else {
|
||||||
// Default to current directory (".")
|
// Default to current directory (".")
|
||||||
target = must.Get(os.Getwd())
|
target = Must(os.Getwd())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all the config values
|
// Get all the config values
|
||||||
getVal := func(prompt string, val *string) {
|
get_val := func(prompt string, val *string) {
|
||||||
fmt.Printf("%s (%q): ", prompt, *val)
|
fmt.Printf("%s (%q): ", prompt, *val)
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
scanner.Scan()
|
scanner.Scan()
|
||||||
@@ -40,31 +40,31 @@ var cmdInit = &cobra.Command{
|
|||||||
*val = input
|
*val = input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pkgOpts := codegen.PkgOpts{
|
pkg_opts := codegen.PkgOpts{
|
||||||
ModuleName: must.Get(cmd.Flags().GetString("module")),
|
ModuleName: Must(cmd.Flags().GetString("module")),
|
||||||
DBFilename: must.Get(cmd.Flags().GetString("db")),
|
DBFilename: Must(cmd.Flags().GetString("db")),
|
||||||
BinaryName: must.Get(cmd.Flags().GetString("binary")),
|
BinaryName: Must(cmd.Flags().GetString("binary")),
|
||||||
}
|
}
|
||||||
if pkgOpts.ModuleName == "" {
|
if pkg_opts.ModuleName == "" {
|
||||||
pkgOpts.ModuleName = filepath.Base(target)
|
pkg_opts.ModuleName = filepath.Base(target)
|
||||||
getVal("module name", &pkgOpts.ModuleName)
|
get_val("module name", &pkg_opts.ModuleName)
|
||||||
}
|
}
|
||||||
if pkgOpts.DBFilename == "" {
|
if pkg_opts.DBFilename == "" {
|
||||||
pkgOpts.DBFilename = pkgOpts.ModuleName + ".db"
|
pkg_opts.DBFilename = pkg_opts.ModuleName + ".db"
|
||||||
getVal("db name", &pkgOpts.DBFilename)
|
get_val("db name", &pkg_opts.DBFilename)
|
||||||
}
|
}
|
||||||
if pkgOpts.BinaryName == "" {
|
if pkg_opts.BinaryName == "" {
|
||||||
pkgOpts.BinaryName = pkgOpts.ModuleName
|
pkg_opts.BinaryName = pkg_opts.ModuleName
|
||||||
getVal("binary name", &pkgOpts.BinaryName)
|
get_val("binary name", &pkg_opts.BinaryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run project initialization
|
// Run project initialization
|
||||||
codegen.InitPkg(pkgOpts)
|
codegen.InitPkg(pkg_opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cmdInit.Flags().String("module", "", "Module name")
|
cmd_init.Flags().String("module", "", "Module name")
|
||||||
cmdInit.Flags().String("db", "", "Database filename")
|
cmd_init.Flags().String("db", "", "Database filename")
|
||||||
cmdInit.Flags().String("binary", "", "Binary name")
|
cmd_init.Flags().String("binary", "", "Binary name")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sqliteLint = &cobra.Command{
|
var sqlite_lint = &cobra.Command{
|
||||||
Use: "sqlite_lint <path/to/schema.sql>",
|
Use: "sqlite_lint <path/to/schema.sql>",
|
||||||
Short: "Lint a SQLite schema",
|
Short: "Lint a SQLite schema",
|
||||||
|
|
||||||
@@ -32,18 +32,18 @@ var sqliteLint = &cobra.Command{
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
isFailure := false
|
is_failure := false
|
||||||
// Execute each check against the database
|
// Execute each check against the database
|
||||||
for _, check := range lint.Checks {
|
for _, check := range lint.Checks {
|
||||||
// Checks can be disabled via Github config / environment variables
|
// Checks can be disabled via Github config / environment variables
|
||||||
if !isCheckEnabled(check) {
|
if !is_check_enabled(check) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results := check.Execute(schema)
|
results := check.Execute(schema)
|
||||||
|
|
||||||
// If there are results, print them as lint errors
|
// If there are results, print them as lint errors
|
||||||
if len(results) > 0 {
|
if len(results) > 0 {
|
||||||
isFailure = true
|
is_failure = true
|
||||||
fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name)
|
fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name)
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
fmt.Printf(RED+"- %s: %s.%s\n"+RESET, result.ErrorMsg, result.TableName, result.ColumnName)
|
fmt.Printf(RED+"- %s: %s.%s\n"+RESET, result.ErrorMsg, result.TableName, result.ColumnName)
|
||||||
@@ -51,7 +51,7 @@ var sqliteLint = &cobra.Command{
|
|||||||
fmt.Printf(RED+"Explanation: %s\n\n"+RESET, check.Explanation)
|
fmt.Printf(RED+"Explanation: %s\n\n"+RESET, check.Explanation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isFailure {
|
if is_failure {
|
||||||
fmt.Println(RED + "Errors found" + RESET)
|
fmt.Println(RED + "Errors found" + RESET)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -61,16 +61,16 @@ var sqliteLint = &cobra.Command{
|
|||||||
|
|
||||||
// github_actions_input_env_var converts an input name to the corresponding
|
// github_actions_input_env_var converts an input name to the corresponding
|
||||||
// environment variable name used by GitHub Actions.
|
// environment variable name used by GitHub Actions.
|
||||||
func githubActionsInputEnvVar(name string) string {
|
func github_actions_input_env_var(name string) string {
|
||||||
// GitHub normalizes both hyphens and underscores to underscores, then uppercases the name
|
// GitHub normalizes both hyphens and underscores to underscores, then uppercases the name
|
||||||
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name)
|
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name)
|
||||||
return "INPUT_" + strings.ToUpper(normalized)
|
return "INPUT_" + strings.ToUpper(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check
|
// Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check
|
||||||
func isCheckEnabled(c lint.Check) bool {
|
func is_check_enabled(c lint.Check) bool {
|
||||||
val, isSet := os.LookupEnv(githubActionsInputEnvVar(c.Name))
|
val, is_set := os.LookupEnv(github_actions_input_env_var(c.Name))
|
||||||
if !isSet {
|
if !is_set {
|
||||||
// Enable all checks by default
|
// Enable all checks by default
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cmdVersion = &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Short: "Print the version of `gas`",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Println(version.String())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
37
doc/TODO.txt
37
doc/TODO.txt
@@ -1,6 +1,13 @@
|
|||||||
TODO: soft-deletion
|
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
|
- 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
|
IDEA: migrations-table
|
||||||
- Store migrations in a table. This makes the schema more self-documenting.
|
- 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)
|
- Possible schema: name, sql_up, sql_down, hash (computed from those fields plus previous migration hash)
|
||||||
@@ -15,38 +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
|
- 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
|
- 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: generated test file inclues global test DB setup, which is wrong
|
||||||
|
|
||||||
TODO: join-tables
|
TODO: join-tables
|
||||||
- handle codegen for without rowid tables properly
|
- handle codegen for without rowid tables properly
|
||||||
- add "get all X for Y"
|
- 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: consider CreateDB and CreateMemoryDB
|
||||||
|
|
||||||
TODO: generate tests for the remaining CRUD, try to hit ~100% default test coverage
|
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
|
|
||||||
|
|
||||||
TODO: crap score
|
|
||||||
|
|
||||||
TODO: MakeSaveItem() fixture factory
|
|
||||||
|
|
||||||
TODO: better schema parsing with less weird views
|
|
||||||
- omp --resume 01a0ef0b-9452-7000-a63a-6d5cabf07646
|
|
||||||
|
|
||||||
TODO: sqlite row-level locking
|
|
||||||
- https://chatgpt.com/c/6ace6870-bf10-83e8-8e0c-98569ae9303f
|
|
||||||
|
|
||||||
TODO: `ops/gas_init_test.sh` "gas generate item_to_item --test"
|
|
||||||
|
|||||||
@@ -29,12 +29,6 @@
|
|||||||
|
|
||||||
- `ops` directory
|
- `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
|
### Borrowed from Ruby On Rails
|
||||||
|
|
||||||
- Convention Over Configuration
|
- Convention Over Configuration
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
https://astexplorer.net/
|
https://astexplorer.net/
|
||||||
https://github.com/dave/dst
|
|
||||||
https://disaev.me/p/writing-useful-go-analysis-linter/
|
|
||||||
https://www.sqlservercentral.com/articles/lookup-table-madness
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
Index page: Table view
|
|
||||||
Create / edit form (doubles as detail view)
|
|
||||||
|
|
||||||
https://chatgpt.com/c/6ab70fe2-0158-83e8-b869-d4b3d8b84192
|
|
||||||
https://chatgpt.com/c/6aa96c42-493c-83e8-927f-aa7b47a7cc9f
|
|
||||||
https://github.com/GoAdminGroup/go-admin?utm_source=chatgpt.com
|
|
||||||
https://github.com/qor/qor
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
|
||||||
cd "$PROJECT_ROOT" # .custom-gcl.yaml is only discovered in the project root.
|
|
||||||
|
|
||||||
# go-lint is public, but the public checksum database has not indexed it yet.
|
|
||||||
# Preserve existing exclusions while fetching the module through the normal
|
|
||||||
# proxy-then-direct resolution path.
|
|
||||||
export GONOSUMDB="${GONOSUMDB:+$GONOSUMDB,}git.offline-twitter.com"
|
|
||||||
|
|
||||||
BIN="bin/golint"
|
|
||||||
STAMP="bin/.golint.stamp"
|
|
||||||
|
|
||||||
# The pinned plugin version is recorded in .custom-gcl.yaml.
|
|
||||||
current_stamp() {
|
|
||||||
sha256sum .custom-gcl.yaml | cut -d' ' -f1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$1" = "--if-needed" ] &&
|
|
||||||
[ -x "$BIN" ] &&
|
|
||||||
[ -f "$STAMP" ] &&
|
|
||||||
[ "$(cat "$STAMP")" = "$(current_stamp)" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! [ -x "$(command -v golangci-lint)" ]; then
|
|
||||||
echo "golangci-lint is not installed. Please install it from https://golangci-lint.run/welcome/install/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p bin
|
|
||||||
echo "Building golint (golangci-lint + shared custom linters)..."
|
|
||||||
golangci-lint custom
|
|
||||||
current_stamp > "$STAMP"
|
|
||||||
|
|
||||||
echo "Done: $PROJECT_ROOT/$BIN"
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# Stamp the version into the binary. `git describe` fails when this isn't a git checkout (e.g. a
|
go build -tags fts5 -o gas ./cmd
|
||||||
# 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.
|
# A dev container.
|
||||||
|
|
||||||
FROM alpine:3.24
|
FROM alpine:3.22
|
||||||
|
|
||||||
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
|
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)
|
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
|
||||||
RUN apk add less
|
RUN apk add less
|
||||||
|
|
||||||
RUN curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b /usr/local/bin v2.14.0
|
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2
|
||||||
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
||||||
|
|
||||||
COPY etc/group /etc/group
|
COPY etc/group /etc/group
|
||||||
|
|||||||
@@ -10,5 +10,4 @@ sudo docker run --rm -it \
|
|||||||
-v /memory:/memory \
|
-v /memory:/memory \
|
||||||
--workdir /code \
|
--workdir /code \
|
||||||
--net host \
|
--net host \
|
||||||
--name gas \
|
|
||||||
gas
|
gas
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ EOF
|
|||||||
$gas generate items > pkg/db/item.go
|
$gas generate items > pkg/db/item.go
|
||||||
$gas generate items --test > pkg/db/item_test.go
|
$gas generate items --test > pkg/db/item_test.go
|
||||||
$gas generate item_to_item > pkg/db/item_to_item.go
|
$gas generate item_to_item > pkg/db/item_to_item.go
|
||||||
# $gas generate item_to_item --test > pkg/db/item_to_item_test.go
|
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
# Run the tests
|
# Run the tests
|
||||||
|
|||||||
16
ops/lint.sh
16
ops/lint.sh
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
./ops/build-lint.sh --if-needed
|
|
||||||
GOLANGCI="./bin/golint"
|
|
||||||
|
|
||||||
echo "Linting $(go list ./... | wc -w) packages"
|
|
||||||
|
|
||||||
# Catches config keys in the wrong place, which are otherwise silently ignored.
|
|
||||||
"$GOLANGCI" config verify
|
|
||||||
"$GOLANGCI" run $@
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
go test -tags fts5 ./... $@
|
|
||||||
@@ -27,18 +27,10 @@ const (
|
|||||||
// we don't have to do that.
|
// we don't have to do that.
|
||||||
var TrailingComments = map[ast.Node]string{}
|
var TrailingComments = map[ast.Node]string{}
|
||||||
|
|
||||||
// mustCall wraps a call expression in must.Get(...), producing AST for must.Get(inner).
|
// mustCall wraps a call expression in Must(...), producing AST for Must(inner).
|
||||||
func mustCall(inner ast.Expr) *ast.CallExpr {
|
func mustCall(inner ast.Expr) *ast.CallExpr {
|
||||||
return &ast.CallExpr{
|
return &ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Get")},
|
Fun: ast.NewIdent("Must"),
|
||||||
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},
|
Args: []ast.Expr{inner},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +73,7 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
|
|||||||
fset = token.NewFileSet()
|
fset = token.NewFileSet()
|
||||||
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
|
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("re-parsing pretty-print: %w\n\n%s", err, buf.String())
|
return fmt.Errorf("re-parsing pretty-print: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel walk: apply TrailingComments from the side map.
|
// Parallel walk: apply TrailingComments from the side map.
|
||||||
|
|||||||
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
111
pkg/codegen/modelgenerate/generate_codetable_type.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package modelgenerate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/token"
|
||||||
|
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateCodetableType(table schema.Table) *ast.GenDecl {
|
||||||
|
return &ast.GenDecl{
|
||||||
|
Tok: token.TYPE,
|
||||||
|
Specs: []ast.Spec{&ast.TypeSpec{Name: &ast.Ident{Name: table.GoTypeName}, Type: &ast.Ident{Name: "int"}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCodetableEnum(table schema.Table, vals []string) *ast.GenDecl {
|
||||||
|
getConstName := func(s string) string {
|
||||||
|
return table.GoTypeName + textutils.KebabToPascal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
constSpecs := []ast.Spec{}
|
||||||
|
for i, val := range vals {
|
||||||
|
spec := &ast.ValueSpec{
|
||||||
|
Names: []*ast.Ident{{Name: getConstName(val)}},
|
||||||
|
}
|
||||||
|
// Only the first one needs `iota`
|
||||||
|
if i == 0 {
|
||||||
|
spec.Type = &ast.Ident{Name: table.GoTypeName}
|
||||||
|
spec.Values = []ast.Expr{&ast.BinaryExpr{
|
||||||
|
X: &ast.Ident{Name: "iota"},
|
||||||
|
Op: token.ADD,
|
||||||
|
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
constSpecs = append(constSpecs, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ast.GenDecl{Tok: token.CONST, Specs: constSpecs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCodetableStringerFunc implements the `Stringer` interface by defining a `String() string` function.
|
||||||
|
func GenerateCodetableStringerFunc(table schema.Table, vals []string) *ast.FuncDecl {
|
||||||
|
objIdent := ast.NewIdent(table.VarName)
|
||||||
|
return &ast.FuncDecl{
|
||||||
|
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{objIdent}, Type: &ast.Ident{Name: table.GoTypeName}}}},
|
||||||
|
Name: &ast.Ident{Name: "String"},
|
||||||
|
Type: &ast.FuncType{Params: &ast.FieldList{}, Results: &ast.FieldList{List: []*ast.Field{{Type: &ast.Ident{Name: "string"}}}}},
|
||||||
|
Body: &ast.BlockStmt{
|
||||||
|
List: []ast.Stmt{
|
||||||
|
// names := []string{ ... }
|
||||||
|
&ast.AssignStmt{
|
||||||
|
Lhs: []ast.Expr{&ast.Ident{Name: "names"}},
|
||||||
|
Tok: token.DEFINE,
|
||||||
|
Rhs: []ast.Expr{
|
||||||
|
&ast.CompositeLit{
|
||||||
|
Type: &ast.ArrayType{Elt: &ast.Ident{Name: "string"}},
|
||||||
|
Elts: func() []ast.Expr {
|
||||||
|
ret := []ast.Expr{
|
||||||
|
&ast.BasicLit{Kind: token.STRING, Value: `""`},
|
||||||
|
}
|
||||||
|
for _, val := range vals {
|
||||||
|
ret = append(ret, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", val)})
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// if int(c) < 1 || int(c) >= len(names) { return "invalid" }
|
||||||
|
&ast.IfStmt{
|
||||||
|
Cond: &ast.BinaryExpr{
|
||||||
|
X: &ast.BinaryExpr{
|
||||||
|
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||||
|
Op: token.LSS,
|
||||||
|
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||||
|
},
|
||||||
|
Op: token.LOR,
|
||||||
|
Y: &ast.BinaryExpr{
|
||||||
|
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
|
||||||
|
Op: token.GEQ,
|
||||||
|
Y: &ast.CallExpr{Fun: &ast.Ident{Name: "len"}, Args: []ast.Expr{&ast.Ident{Name: "names"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Body: &ast.BlockStmt{
|
||||||
|
List: []ast.Stmt{
|
||||||
|
&ast.ReturnStmt{Results: []ast.Expr{
|
||||||
|
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")}, Args: []ast.Expr{
|
||||||
|
&ast.BasicLit{Kind: token.STRING, Value: `"<%d=invalid>"`},
|
||||||
|
objIdent,
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// return names[c]
|
||||||
|
&ast.ReturnStmt{
|
||||||
|
Results: []ast.Expr{
|
||||||
|
&ast.IndexExpr{
|
||||||
|
X: &ast.Ident{Name: "names"},
|
||||||
|
Index: objIdent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/token"
|
"go/token"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jinzhu/inflection"
|
"github.com/jinzhu/inflection"
|
||||||
@@ -24,7 +23,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
|
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
|
||||||
return ast.NewIdent(strings.ToLower(tbl.GoTypeName[:1]) + tbl.GoTypeName[1:] + "SQLFields")
|
return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoTypeForColumn returns a type expression for this column.
|
// GoTypeForColumn returns a type expression for this column.
|
||||||
@@ -54,48 +53,20 @@ func GoTypeForColumn(c schema.Column) ast.Expr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustBeRowsAffected produces an AST for a `must.Be(...)` call asserting that exactly one row
|
func PanicIfRowsAffected(tbl schema.Table) *ast.IfStmt {
|
||||||
// was affected by the preceding statement, e.g.:
|
return &ast.IfStmt{
|
||||||
//
|
Cond: &ast.BinaryExpr{
|
||||||
// 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{
|
X: mustCall(&ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
|
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
|
||||||
Args: []ast.Expr{},
|
Args: []ast.Expr{},
|
||||||
}),
|
}),
|
||||||
Op: token.EQL,
|
Op: token.NEQ,
|
||||||
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
|
||||||
},
|
},
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", msg)},
|
Body: &ast.BlockStmt{List: []ast.Stmt{
|
||||||
ast.NewIdent("ErrNotInDB"),
|
&ast.ExprStmt{X: &ast.CallExpr{Fun: ast.NewIdent("panic"), Args: []ast.Expr{ast.NewIdent(tbl.VarName)}}},
|
||||||
}, pkArgs...)
|
}},
|
||||||
|
}
|
||||||
return &ast.ExprStmt{X: &ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Be")},
|
|
||||||
Args: args,
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------
|
// ---------------
|
||||||
@@ -190,92 +161,79 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
|
|||||||
structFieldName := col.GoFieldName()
|
structFieldName := col.GoFieldName()
|
||||||
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
|
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
|
||||||
|
|
||||||
ret = append(ret, func() ast.Stmt {
|
if col.IsNonCodeTableForeignKey() {
|
||||||
// Wrap nullable FKs in "if a.val != 0 { ... }"
|
// Real foreign key; look up referent by ID to see if it exists
|
||||||
wrap := func(input ast.Stmt) ast.Stmt {
|
ret = append(ret, &ast.IfStmt{
|
||||||
if col.IsNullableForeignKey() {
|
Init: &ast.AssignStmt{
|
||||||
return &ast.IfStmt{
|
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
||||||
Cond: &ast.BinaryExpr{X: structField, Op: token.NEQ, Y: &ast.BasicLit{Kind: token.INT, Value: "0"}},
|
Tok: token.DEFINE,
|
||||||
Body: &ast.BlockStmt{List: []ast.Stmt{input}},
|
Rhs: []ast.Expr{
|
||||||
}
|
&ast.CallExpr{
|
||||||
} else {
|
Fun: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent(getByIDFuncName(col.ForeignKeyTargetTable))},
|
||||||
return input
|
Args: []ast.Expr{structField},
|
||||||
}
|
|
||||||
}
|
|
||||||
if col.IsNonCodeTableForeignKey() {
|
|
||||||
// Real foreign key; look up referent by ID to see if it exists
|
|
||||||
return wrap(&ast.IfStmt{
|
|
||||||
Init: &ast.AssignStmt{
|
|
||||||
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
|
||||||
Tok: token.DEFINE,
|
|
||||||
Rhs: []ast.Expr{
|
|
||||||
&ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent(getByIDFuncName(col.ForeignKeyTargetTable))},
|
|
||||||
Args: []ast.Expr{structField},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cond: &ast.CallExpr{
|
},
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
|
Cond: &ast.CallExpr{
|
||||||
Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")},
|
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
|
||||||
},
|
Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")},
|
||||||
Body: &ast.BlockStmt{
|
},
|
||||||
List: []ast.Stmt{
|
Body: &ast.BlockStmt{
|
||||||
&ast.ReturnStmt{
|
List: []ast.Stmt{
|
||||||
Results: []ast.Expr{
|
&ast.ReturnStmt{
|
||||||
&ast.CallExpr{
|
Results: []ast.Expr{
|
||||||
Fun: ast.NewIdent("NewForeignKeyError"),
|
&ast.CallExpr{
|
||||||
Args: []ast.Expr{
|
Fun: ast.NewIdent("NewForeignKeyError"),
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
|
Args: []ast.Expr{
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
|
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
|
||||||
structField,
|
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
|
||||||
},
|
structField,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
} else {
|
})
|
||||||
// Code table value. Query the table to see if it exists
|
} else {
|
||||||
return wrap(&ast.IfStmt{
|
// Code table value. Query the table to see if it exists
|
||||||
Init: &ast.AssignStmt{
|
ret = append(ret, &ast.IfStmt{
|
||||||
Lhs: []ast.Expr{ast.NewIdent("err")},
|
Init: &ast.AssignStmt{
|
||||||
Tok: token.DEFINE,
|
Lhs: []ast.Expr{ast.NewIdent("err")},
|
||||||
Rhs: []ast.Expr{
|
Tok: token.ASSIGN,
|
||||||
&ast.CallExpr{
|
Rhs: []ast.Expr{
|
||||||
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
|
&ast.CallExpr{
|
||||||
Args: []ast.Expr{
|
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
|
||||||
&ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
|
Args: []ast.Expr{
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)},
|
&ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
|
||||||
structField,
|
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)},
|
||||||
},
|
structField,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cond: &ast.CallExpr{
|
},
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
|
Cond: &ast.CallExpr{
|
||||||
Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}},
|
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{
|
Body: &ast.BlockStmt{
|
||||||
&ast.ReturnStmt{
|
List: []ast.Stmt{
|
||||||
Results: []ast.Expr{
|
&ast.ReturnStmt{
|
||||||
&ast.CallExpr{
|
Results: []ast.Expr{
|
||||||
Fun: ast.NewIdent("NewForeignKeyError"),
|
&ast.CallExpr{
|
||||||
Args: []ast.Expr{
|
Fun: ast.NewIdent("NewForeignKeyError"),
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
|
Args: []ast.Expr{
|
||||||
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
|
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
|
||||||
structField,
|
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
|
||||||
},
|
structField,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
}())
|
}
|
||||||
}
|
}
|
||||||
// final return nil
|
// final return nil
|
||||||
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
|
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
|
||||||
@@ -297,7 +255,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
|
|
||||||
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||||
|
|
||||||
// Assemble SQL "insert" string
|
// Assemble data for building SQL "insert" and "update" strings
|
||||||
for _, col := range tbl.Columns {
|
for _, col := range tbl.Columns {
|
||||||
if col.Name == "rowid" {
|
if col.Name == "rowid" {
|
||||||
continue
|
continue
|
||||||
@@ -308,20 +266,19 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
val = fmt.Sprintf("nullif(%s, 0)", val)
|
val = fmt.Sprintf("nullif(%s, 0)", val)
|
||||||
}
|
}
|
||||||
insertVals = append(insertVals, val)
|
insertVals = append(insertVals, val)
|
||||||
|
// created_at should not be updated after creation
|
||||||
|
if col.Name == "created_at" && hasCreatedAt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
|
||||||
|
updatePairs = append(updatePairs, col.Name+"="+val)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
|
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
|
||||||
tbl.TableName,
|
tbl.TableName,
|
||||||
strings.Join(insertCols, ", "),
|
strings.Join(insertCols, ", "),
|
||||||
strings.Join(insertVals, ", "),
|
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)
|
|
||||||
}
|
|
||||||
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\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,
|
tbl.TableName,
|
||||||
strings.Join(updatePairs, ",\n\t\t "),
|
strings.Join(updatePairs, ",\n\t\t "),
|
||||||
@@ -363,7 +320,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !hasFks {
|
if !hasFks {
|
||||||
// No foreign key checking needed; just use `must.Get` for brevity
|
// No foreign key checking needed; just use `Must` for brevity
|
||||||
return []ast.Stmt{&ast.AssignStmt{
|
return []ast.Stmt{&ast.AssignStmt{
|
||||||
Lhs: []ast.Expr{ast.NewIdent("result")},
|
Lhs: []ast.Expr{ast.NewIdent("result")},
|
||||||
Tok: token.DEFINE,
|
Tok: token.DEFINE,
|
||||||
@@ -429,26 +386,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tbl.IsWithoutRowid {
|
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, namedExecStmt(upsertStmt)...)
|
||||||
ret = append(ret, MustBeRowsAffected(tbl))
|
ret = append(ret, PanicIfRowsAffected(tbl))
|
||||||
} else {
|
} else {
|
||||||
// if item.ID == 0 {...} else {...}
|
// if item.ID == 0 {...} else {...}
|
||||||
ret = append(ret, &ast.IfStmt{
|
ret = append(ret, &ast.IfStmt{
|
||||||
@@ -464,21 +403,10 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
ret1 := []ast.Stmt{Comment("Do create")}
|
ret1 := []ast.Stmt{Comment("Do create")}
|
||||||
if hasCreatedAt {
|
if hasCreatedAt {
|
||||||
// Auto-timestamps: created_at
|
// Auto-timestamps: created_at
|
||||||
ret1 = append(ret1, &ast.IfStmt{
|
ret1 = append(ret1, &ast.AssignStmt{
|
||||||
// Don't overwrite existing timestamps. This is useful for various reasons, e.g., data import / migrations
|
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
|
||||||
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{
|
Tok: token.ASSIGN,
|
||||||
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")},
|
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
|
||||||
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)...)
|
return append(ret1, namedExecStmt(insertStmt)...)
|
||||||
@@ -502,7 +430,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
[]ast.Stmt{Comment("Do update")},
|
[]ast.Stmt{Comment("Do update")},
|
||||||
append(
|
append(
|
||||||
namedExecStmt(updateStmt),
|
namedExecStmt(updateStmt),
|
||||||
MustBeRowsAffected(tbl),
|
PanicIfRowsAffected(tbl),
|
||||||
)...,
|
)...,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -516,16 +444,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{
|
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,
|
Recv: dbRecv,
|
||||||
Name: ast.NewIdent("Save" + tbl.GoTypeName),
|
Name: ast.NewIdent("Save" + tbl.GoTypeName),
|
||||||
Type: &ast.FuncType{
|
Type: &ast.FuncType{
|
||||||
@@ -546,50 +469,15 @@ func getByIDFuncName(tblname string) string {
|
|||||||
return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
|
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 {
|
func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
||||||
paramNames := paramNamesFor(cols)
|
|
||||||
|
|
||||||
colNames := []string{}
|
colNames := []string{}
|
||||||
funcNameSuffix := []string{}
|
funcNameSuffix := []string{}
|
||||||
funcParams := &ast.FieldList{List: []*ast.Field{}}
|
funcParams := &ast.FieldList{List: []*ast.Field{}}
|
||||||
sqlParams := []ast.Expr{}
|
sqlParams := []ast.Expr{}
|
||||||
for i, col := range cols {
|
for _, col := range cols {
|
||||||
funcParam := ast.NewIdent(paramNames[i])
|
funcParam := ast.NewIdent(col.LongGoVarName())
|
||||||
funcParams.List = append(funcParams.List, &ast.Field{Names: []*ast.Ident{funcParam}, Type: GoTypeForColumn(col)})
|
funcParams.List = append(funcParams.List, &ast.Field{Names: []*ast.Ident{funcParam}, Type: GoTypeForColumn(col)})
|
||||||
colNames = append(colNames, fmt.Sprintf("%s = ?", col.Name))
|
colNames = append(colNames, fmt.Sprintf("%s = :%s", col.Name, col.Name))
|
||||||
funcNameSuffix = append(funcNameSuffix, col.GoFieldName())
|
funcNameSuffix = append(funcNameSuffix, col.GoFieldName())
|
||||||
sqlParams = append(sqlParams, funcParam)
|
sqlParams = append(sqlParams, funcParam)
|
||||||
}
|
}
|
||||||
@@ -601,7 +489,7 @@ func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
|||||||
Y: SQLFieldsConstIdent(tbl),
|
Y: SQLFieldsConstIdent(tbl),
|
||||||
},
|
},
|
||||||
Op: token.ADD,
|
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 "))},
|
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{
|
return &ast.FuncDecl{
|
||||||
@@ -627,8 +515,7 @@ func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
|||||||
&ast.IfStmt{
|
&ast.IfStmt{
|
||||||
Cond: &ast.CallExpr{
|
Cond: &ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
|
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")}},
|
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")}}}},
|
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ReturnStmt{Results: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent(tbl.GoTypeName)}, ast.NewIdent("ErrNotInDB")}}}},
|
||||||
},
|
},
|
||||||
&ast.ReturnStmt{},
|
&ast.ReturnStmt{},
|
||||||
@@ -677,33 +564,10 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByCol()` function, for a unique
|
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function.
|
||||||
// index on a single column.
|
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
|
||||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByName()" function.
|
|
||||||
func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
|
func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
|
||||||
return GenerateGetItemBy(tbl, []schema.Column{col})
|
// Use the xyzSQLFields constant in the select query
|
||||||
}
|
|
||||||
|
|
||||||
// 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{
|
selectExpr := &ast.BinaryExpr{
|
||||||
X: &ast.BinaryExpr{
|
X: &ast.BinaryExpr{
|
||||||
X: &ast.BasicLit{Kind: token.STRING, Value: "`\n\t select `"},
|
X: &ast.BasicLit{Kind: token.STRING, Value: "`\n\t select `"},
|
||||||
@@ -711,40 +575,40 @@ func GenerateGetItemsBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
|
|||||||
Y: SQLFieldsConstIdent(tbl),
|
Y: SQLFieldsConstIdent(tbl),
|
||||||
},
|
},
|
||||||
Op: token.ADD,
|
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 "))},
|
Y: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`\n\t from %s\n\t where %s = ?\n\t`", tbl.TableName, col.Name)},
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCall := doCall(&ast.CallExpr{
|
param := ast.NewIdent(col.GoVarName())
|
||||||
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{
|
return &ast.FuncDecl{
|
||||||
Recv: dbRecv,
|
Recv: dbRecv,
|
||||||
Name: ast.NewIdent("Get" + inflection.Plural(schema.TypenameFromTablename(tbl.TableName)) + "By" + strings.Join(funcNameSuffix, "And")),
|
Name: ast.NewIdent("Get" + schema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName()),
|
||||||
Type: &ast.FuncType{
|
Type: &ast.FuncType{
|
||||||
Params: funcParams,
|
Params: &ast.FieldList{List: []*ast.Field{
|
||||||
|
{Names: []*ast.Ident{param}, Type: GoTypeForColumn(col)},
|
||||||
|
}},
|
||||||
Results: &ast.FieldList{List: []*ast.Field{
|
Results: &ast.FieldList{List: []*ast.Field{
|
||||||
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
|
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: ast.NewIdent(tbl.GoTypeName)},
|
||||||
|
{Names: []*ast.Ident{ast.NewIdent("err")}, Type: ast.NewIdent("error")},
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
Body: &ast.BlockStmt{
|
Body: &ast.BlockStmt{
|
||||||
List: []ast.Stmt{
|
List: []ast.Stmt{
|
||||||
&ast.ExprStmt{X: selectCall},
|
&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.ReturnStmt{},
|
&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.
|
// GenerateGetAllItemsFunc produces an AST for the `GetAllXyzs()` function.
|
||||||
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
|
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
|
||||||
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
||||||
@@ -753,24 +617,29 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
|
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
selectCall := doCall(&ast.CallExpr{
|
selectCall := &ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{
|
Fun: ast.NewIdent("PanicIf"),
|
||||||
X: dbDB,
|
|
||||||
Sel: ast.NewIdent("Select"),
|
|
||||||
},
|
|
||||||
Args: []ast.Expr{
|
Args: []ast.Expr{
|
||||||
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
|
&ast.CallExpr{
|
||||||
&ast.BinaryExpr{
|
Fun: &ast.SelectorExpr{
|
||||||
X: &ast.BinaryExpr{
|
X: dbDB,
|
||||||
X: &ast.BasicLit{Kind: token.STRING, Value: "`select `"},
|
Sel: ast.NewIdent("Select"),
|
||||||
Op: token.ADD,
|
},
|
||||||
Y: SQLFieldsConstIdent(tbl),
|
Args: []ast.Expr{
|
||||||
|
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
|
||||||
|
&ast.BinaryExpr{
|
||||||
|
X: &ast.BinaryExpr{
|
||||||
|
X: &ast.BasicLit{Kind: token.STRING, Value: "`select `"},
|
||||||
|
Op: token.ADD,
|
||||||
|
Y: SQLFieldsConstIdent(tbl),
|
||||||
|
},
|
||||||
|
Op: token.ADD,
|
||||||
|
Y: &ast.BasicLit{Kind: token.STRING, Value: "` from " + tbl.TableName + "`"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Op: token.ADD,
|
|
||||||
Y: &ast.BasicLit{Kind: token.STRING, Value: "` from " + tbl.TableName + "`"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
funcBody := &ast.BlockStmt{
|
funcBody := &ast.BlockStmt{
|
||||||
List: []ast.Stmt{
|
List: []ast.Stmt{
|
||||||
@@ -813,7 +682,7 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
},
|
},
|
||||||
})},
|
})},
|
||||||
},
|
},
|
||||||
MustBeRowsAffected(tbl),
|
PanicIfRowsAffected(tbl),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,66 +698,6 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
|
|||||||
return 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.
|
// GenerateSQLFieldsConst produces an AST for the `const xyzSQLFields = ...` string.
|
||||||
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
|
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
|
||||||
columns := make([]string, 0, len(tbl.Columns))
|
columns := make([]string, 0, len(tbl.Columns))
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/token"
|
"go/token"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jinzhu/inflection"
|
"github.com/jinzhu/inflection"
|
||||||
|
|
||||||
@@ -13,77 +11,6 @@ import (
|
|||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
"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.
|
// 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 {
|
func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodName string) *ast.File {
|
||||||
packageName := "db"
|
packageName := "db"
|
||||||
@@ -91,57 +18,11 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
|
|
||||||
makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName)
|
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{} }
|
// func MakeItem() Item { return Item{} }
|
||||||
makeItemFunc := &ast.FuncDecl{
|
makeItemFunc := &ast.FuncDecl{
|
||||||
Name: makeHelperName,
|
Name: makeHelperName,
|
||||||
Type: &ast.FuncType{
|
Type: &ast.FuncType{
|
||||||
Params: &ast.FieldList{List: func() (ret []*ast.Field) {
|
Params: &ast.FieldList{},
|
||||||
for i, c := range requiredForeignKeys {
|
|
||||||
ret = append(ret, &ast.Field{
|
|
||||||
Names: []*ast.Ident{ast.NewIdent(requiredForeignKeyParamNames[i])},
|
|
||||||
Type: GoTypeForColumn(c),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}()},
|
|
||||||
Results: &ast.FieldList{
|
Results: &ast.FieldList{
|
||||||
List: []*ast.Field{
|
List: []*ast.Field{
|
||||||
{Type: ast.NewIdent(tbl.GoTypeName)},
|
{Type: ast.NewIdent(tbl.GoTypeName)},
|
||||||
@@ -149,142 +30,44 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Body: &ast.BlockStmt{
|
Body: &ast.BlockStmt{
|
||||||
List: append(
|
List: []ast.Stmt{
|
||||||
func() []ast.Stmt {
|
&ast.ReturnStmt{
|
||||||
if !hasRandomUniqueField {
|
Results: []ast.Expr{
|
||||||
return nil
|
&ast.CompositeLit{
|
||||||
}
|
Type: ast.NewIdent(tbl.GoTypeName),
|
||||||
return []ast.Stmt{&ast.AssignStmt{
|
Elts: []ast.Expr{
|
||||||
Lhs: []ast.Expr{ast.NewIdent("id")},
|
&ast.KeyValueExpr{
|
||||||
Tok: token.DEFINE,
|
Key: ast.NewIdent("Data"),
|
||||||
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("rand"), Sel: ast.NewIdent("Int")}}},
|
Value: &ast.CompositeLit{
|
||||||
}}
|
Type: &ast.ArrayType{
|
||||||
}(),
|
Elt: ast.NewIdent("byte"),
|
||||||
[]ast.Stmt{
|
},
|
||||||
&ast.ReturnStmt{
|
Elts: []ast.Expr{},
|
||||||
Results: []ast.Expr{
|
},
|
||||||
&ast.CompositeLit{
|
},
|
||||||
Type: ast.NewIdent(tbl.GoTypeName),
|
&ast.KeyValueExpr{
|
||||||
Elts: func() (ret []ast.Expr) {
|
Key: ast.NewIdent("Description"),
|
||||||
// `UpdateTestFields()` skips foreign keys, so this will not cause duplicates
|
Value: &ast.BasicLit{
|
||||||
for i, c := range requiredForeignKeys {
|
Kind: token.STRING,
|
||||||
ret = append(ret, &ast.KeyValueExpr{
|
Value: `""`,
|
||||||
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))
|
testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
|
||||||
testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
|
testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
|
||||||
|
fieldName := ast.NewIdent("Description") // TODO
|
||||||
|
description1 := `"an item"`
|
||||||
|
description2 := `"a big item"`
|
||||||
testDB := ast.NewIdent("TestDB")
|
testDB := ast.NewIdent("TestDB")
|
||||||
|
|
||||||
// getItemByPKCall builds a call to this table's primary-key getter (e.g. `TestDB.GetItemByID(item.ID)`,
|
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
|
||||||
// or `TestDB.GetItemByColAAndColB(item.ColA, item.ColB)` for "without rowid" tables with a
|
|
||||||
// compound primary key), matching whatever GenerateGetItemByIDFunc/GenerateGetItemBy generated.
|
|
||||||
getItemByPKCall := func(obj *ast.Ident) *ast.CallExpr {
|
|
||||||
if !tbl.IsWithoutRowid {
|
|
||||||
// Normal rowid table: use GetXyzByID
|
|
||||||
return &ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
|
|
||||||
Args: []ast.Expr{&ast.SelectorExpr{X: obj, Sel: ast.NewIdent("ID")}},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// "Without rowid" table: use the primary key "GetItemByBlahBlah" query func
|
|
||||||
funcNameSuffix := []string{}
|
|
||||||
args := []ast.Expr{}
|
|
||||||
for _, c := range tbl.PrimaryKeyColumns() {
|
|
||||||
funcNameSuffix = append(funcNameSuffix, c.GoFieldName())
|
|
||||||
args = append(args, &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(c.GoFieldName())})
|
|
||||||
}
|
|
||||||
return &ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "By" + strings.Join(funcNameSuffix, "And"))},
|
|
||||||
Args: args,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
makeDeepEqual := func(obj1 *ast.Ident, obj2 *ast.Ident) *ast.IfStmt {
|
|
||||||
return &ast.IfStmt{
|
|
||||||
Init: &ast.AssignStmt{
|
|
||||||
Lhs: []ast.Expr{
|
|
||||||
&ast.Ident{Name: "diff"},
|
|
||||||
},
|
|
||||||
Tok: token.DEFINE,
|
|
||||||
Rhs: []ast.Expr{
|
|
||||||
&ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{
|
|
||||||
X: &ast.Ident{Name: "deep"},
|
|
||||||
Sel: &ast.Ident{Name: "Equal"},
|
|
||||||
},
|
|
||||||
Args: []ast.Expr{obj1, obj2},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Cond: &ast.BinaryExpr{
|
|
||||||
X: &ast.Ident{Name: "diff"},
|
|
||||||
Op: token.NEQ,
|
|
||||||
Y: &ast.Ident{Name: "nil"},
|
|
||||||
},
|
|
||||||
Body: &ast.BlockStmt{
|
|
||||||
List: []ast.Stmt{
|
|
||||||
&ast.ExprStmt{
|
|
||||||
X: &ast.CallExpr{
|
|
||||||
Fun: &ast.SelectorExpr{
|
|
||||||
X: &ast.Ident{Name: "t"},
|
|
||||||
Sel: &ast.Ident{Name: "Error"},
|
|
||||||
},
|
|
||||||
Args: []ast.Expr{
|
|
||||||
&ast.Ident{Name: "diff"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testFuncType := &ast.FuncType{
|
testFuncType := &ast.FuncType{
|
||||||
Params: &ast.FieldList{
|
Params: &ast.FieldList{
|
||||||
@@ -295,104 +78,6 @@ 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{
|
testCreateUpdateDelete := &ast.FuncDecl{
|
||||||
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
|
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
|
||||||
Type: testFuncType,
|
Type: testFuncType,
|
||||||
@@ -412,47 +97,36 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
&ast.AssignStmt{
|
&ast.AssignStmt{
|
||||||
Lhs: []ast.Expr{testObj},
|
Lhs: []ast.Expr{testObj},
|
||||||
Tok: token.DEFINE,
|
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>
|
// item.Description = "an item"
|
||||||
// ...one assignment per updatable field
|
&ast.AssignStmt{
|
||||||
}
|
|
||||||
for _, c := range updateTestFields {
|
|
||||||
stmts = append(stmts, &ast.AssignStmt{
|
|
||||||
Lhs: []ast.Expr{
|
Lhs: []ast.Expr{
|
||||||
&ast.SelectorExpr{
|
&ast.SelectorExpr{
|
||||||
X: testObj,
|
X: testObj,
|
||||||
Sel: ast.NewIdent(c.GoFieldName()),
|
Sel: ast.NewIdent("Description"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Tok: token.ASSIGN,
|
Tok: token.ASSIGN,
|
||||||
Rhs: []ast.Expr{SampleValue(c, 1)},
|
Rhs: []ast.Expr{
|
||||||
})
|
&ast.BasicLit{
|
||||||
}
|
Kind: token.STRING,
|
||||||
stmts = append(stmts,
|
Value: fmt.Sprintf("%q", description1),
|
||||||
// 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)
|
// TestDB.SaveItem(&item)
|
||||||
if !tbl.IsWithoutRowid { // non-rowid tables don't get an ID
|
&ast.ExprStmt{X: &ast.CallExpr{
|
||||||
stmts = append(stmts, &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}},
|
||||||
|
}},
|
||||||
|
|
||||||
|
// require.NotZero(t, item.ID)
|
||||||
|
&ast.ExprStmt{X: &ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NotZero")},
|
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")}},
|
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
|
||||||
}})
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// After create: assert timestamps are set
|
// After create: assert timestamps are set
|
||||||
@@ -467,51 +141,64 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
BlankLine(),
|
BlankLine(),
|
||||||
Comment("Load"),
|
Comment("Load"),
|
||||||
|
|
||||||
// item2 := must.Get(TestDB.GetItemByID(item.ID))
|
// item2 := Must(TestDB.GetItemByID(item.ID))
|
||||||
&ast.AssignStmt{
|
&ast.AssignStmt{
|
||||||
Lhs: []ast.Expr{testObj2},
|
Lhs: []ast.Expr{testObj2},
|
||||||
Tok: token.DEFINE,
|
Tok: token.DEFINE,
|
||||||
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
|
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")}},
|
||||||
|
})},
|
||||||
},
|
},
|
||||||
|
|
||||||
// if deep.Equal(...) {...}
|
// assert.Equal(t, item.Description, item2.Description)
|
||||||
makeDeepEqual(testObj, testObj2),
|
&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 len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
|
stmts = append(stmts,
|
||||||
stmts = append(stmts,
|
BlankLine(),
|
||||||
BlankLine(),
|
Comment("Update"),
|
||||||
Comment("Update"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// item.Description = <sample value, offset 2>
|
// item.Description = "a big item"
|
||||||
// ...one assignment per updatable field
|
&ast.AssignStmt{
|
||||||
for _, c := range updateTestFields {
|
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
|
||||||
stmts = append(stmts, &ast.AssignStmt{
|
Tok: token.ASSIGN,
|
||||||
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}},
|
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
|
||||||
Tok: token.ASSIGN,
|
},
|
||||||
Rhs: []ast.Expr{SampleValue(c, 2)},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stmts = append(stmts,
|
// TestDB.SaveItem(&item)
|
||||||
// TestDB.SaveItem(&item)
|
&ast.ExprStmt{X: &ast.CallExpr{
|
||||||
&ast.ExprStmt{X: &ast.CallExpr{
|
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
|
||||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
|
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
|
||||||
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
|
}},
|
||||||
}},
|
|
||||||
|
|
||||||
// item2 = must.Get(TestDB.GetItemByID(item.ID))
|
// item2 = Must(TestDB.GetItemByID(item.ID))
|
||||||
&ast.AssignStmt{
|
&ast.AssignStmt{
|
||||||
Lhs: []ast.Expr{testObj2},
|
Lhs: []ast.Expr{testObj2},
|
||||||
Tok: token.ASSIGN,
|
Tok: token.ASSIGN,
|
||||||
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
|
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")}},
|
||||||
|
})},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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{
|
indexGets, hasIndexedGets := []ast.Stmt{
|
||||||
BlankLine(),
|
BlankLine(),
|
||||||
@@ -523,21 +210,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
// Skip indexes on other tables
|
// Skip indexes on other tables
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(index.Columns, "") {
|
if index.IsUnique && len(index.Columns) == 1 {
|
||||||
// Skip expression indexes; there's no way to resolve an expression to a real column
|
col := tbl.GetColumnByName(index.Columns[0])
|
||||||
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{
|
indexGets = append(indexGets, []ast.Stmt{
|
||||||
// assert.Equal(t, item2, TestDB.GetItemByXYZ(...))
|
// assert.Equal(t, item2, TestDB.GetItemByXYZ(...))
|
||||||
&ast.ExprStmt{X: &ast.CallExpr{ // TODO: what if just delete the "ExprStmt" wrapper?
|
&ast.ExprStmt{X: &ast.CallExpr{ // TODO: what if just delete the "ExprStmt" wrapper?
|
||||||
@@ -547,33 +221,16 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
testObj2,
|
testObj2,
|
||||||
mustCall(&ast.CallExpr{
|
mustCall(&ast.CallExpr{
|
||||||
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
|
||||||
"Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + funcNameSuffix,
|
"Get" + pkgschema.TypenameFromTablename(tbl.TableName) + "By" + col.GoFieldName(),
|
||||||
)},
|
)},
|
||||||
Args: callArgs,
|
Args: []ast.Expr{&ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(col.GoFieldName())}},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
}...)
|
}...)
|
||||||
} else {
|
// decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
|
||||||
indexGets = append(indexGets, []ast.Stmt{
|
hasIndexedGets = true
|
||||||
// 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 {
|
if hasIndexedGets {
|
||||||
@@ -594,7 +251,10 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
&ast.AssignStmt{
|
&ast.AssignStmt{
|
||||||
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
|
||||||
Tok: token.DEFINE,
|
Tok: token.DEFINE,
|
||||||
Rhs: []ast.Expr{getItemByPKCall(testObj)},
|
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")}},
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
|
|
||||||
// assert.ErrorIs(t, err, db.ErrNotInDB)
|
// assert.ErrorIs(t, err, db.ErrNotInDB)
|
||||||
@@ -632,6 +292,93 @@ 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
shouldDefineErr := true
|
||||||
|
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: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[shouldDefineErr],
|
||||||
|
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()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}...)
|
||||||
|
shouldDefineErr = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stmts
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
testList := []ast.Decl{
|
testList := []ast.Decl{
|
||||||
makeItemFunc,
|
makeItemFunc,
|
||||||
testCreateUpdateDelete,
|
testCreateUpdateDelete,
|
||||||
@@ -645,33 +392,23 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
Decls: append([]ast.Decl{
|
Decls: append([]ast.Decl{
|
||||||
&ast.GenDecl{
|
&ast.GenDecl{
|
||||||
Tok: token.IMPORT,
|
Tok: token.IMPORT,
|
||||||
Specs: func() []ast.Spec {
|
Specs: []ast.Spec{
|
||||||
specs := []ast.Spec{}
|
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
|
||||||
if hasRandomUniqueField {
|
&ast.ImportSpec{
|
||||||
specs = append(specs,
|
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
|
||||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
|
Name: ast.NewIdent("."),
|
||||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"math/rand"`}},
|
},
|
||||||
)
|
&ast.ImportSpec{
|
||||||
}
|
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
|
||||||
specs = append(specs, []ast.Spec{
|
Name: ast.NewIdent("."),
|
||||||
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
|
},
|
||||||
&ast.ImportSpec{
|
&ast.ImportSpec{
|
||||||
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
|
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
|
||||||
Name: ast.NewIdent("."),
|
Name: ast.NewIdent("."),
|
||||||
},
|
},
|
||||||
&ast.ImportSpec{
|
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}},
|
||||||
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: `"github.com/stretchr/testify/require"`}},
|
||||||
},
|
},
|
||||||
&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...),
|
}, testList...),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed "tpl"
|
//go:embed "tpl"
|
||||||
@@ -22,31 +22,31 @@ type PkgOpts struct {
|
|||||||
func InitPkg(opts PkgOpts) {
|
func InitPkg(opts PkgOpts) {
|
||||||
// Run `go mod init`
|
// Run `go mod init`
|
||||||
fmt.Printf("Running... `go mod init %s`\n", opts.ModuleName)
|
fmt.Printf("Running... `go mod init %s`\n", opts.ModuleName)
|
||||||
must.Do(exec.Command("go", "mod", "init", opts.ModuleName).Run())
|
PanicIf(exec.Command("go", "mod", "init", opts.ModuleName).Run())
|
||||||
|
|
||||||
// Run `git init`, if required
|
// Run `git init`, if required
|
||||||
if exec.Command("git", "status").Run() != nil {
|
if exec.Command("git", "status").Run() != nil {
|
||||||
// Not in a git repo yet; init one
|
// Not in a git repo yet; init one
|
||||||
fmt.Println("Running... `git init`")
|
fmt.Println("Running... `git init`")
|
||||||
must.Do(exec.Command("git", "init").Run())
|
PanicIf(exec.Command("git", "init").Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create package structure
|
// Create package structure
|
||||||
must.Do(os.MkdirAll("pkg/db", 0o755))
|
PanicIf(os.MkdirAll("pkg/db", 0o755))
|
||||||
must.Do(os.MkdirAll("cmd", 0o755))
|
PanicIf(os.MkdirAll("cmd", 0o755))
|
||||||
must.Do(os.MkdirAll("doc", 0o755))
|
PanicIf(os.MkdirAll("doc", 0o755))
|
||||||
must.Do(os.MkdirAll("sample_data", 0o755))
|
PanicIf(os.MkdirAll("sample_data", 0o755))
|
||||||
|
|
||||||
must.Do(os.WriteFile("pkg/db/schema.sql", must.Get(tpl.ReadFile("tpl/schema.sql")), 0o664))
|
PanicIf(os.WriteFile("pkg/db/schema.sql", Must(tpl.ReadFile("tpl/schema.sql")), 0o664))
|
||||||
must.Do(os.WriteFile("pkg/db/db.go", must.Get(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
|
PanicIf(os.WriteFile("pkg/db/db.go", Must(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
|
||||||
|
|
||||||
dbTest := must.Get(os.Create("pkg/db/db_test.go"))
|
dbTest := Must(os.Create("pkg/db/db_test.go"))
|
||||||
defer must.Close(dbTest)
|
defer MustClose(dbTest)
|
||||||
t := must.Get(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
|
t := Must(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
|
||||||
must.Do(t.Execute(dbTest, opts))
|
PanicIf(t.Execute(dbTest, opts))
|
||||||
|
|
||||||
must.Do(os.WriteFile("sample_data/mount.sh", must.Get(tpl.ReadFile("tpl/mount.sh")), 0o775))
|
PanicIf(os.WriteFile("sample_data/mount.sh", Must(tpl.ReadFile("tpl/mount.sh")), 0o775))
|
||||||
must.Do(os.WriteFile("sample_data/reset.sh", must.Get(tpl.ReadFile("tpl/reset.sh")), 0o775))
|
PanicIf(os.WriteFile("sample_data/reset.sh", Must(tpl.ReadFile("tpl/reset.sh")), 0o775))
|
||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
// - create `pkg/db/errors.go`
|
// - create `pkg/db/errors.go`
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
var sql_schema string
|
var sql_schema string
|
||||||
|
|
||||||
// Database starts at version 0. First migration brings us to version 1
|
// Database starts at version 0. First migration brings us to version 1
|
||||||
var MIGRATIONS = []db.Migration{}
|
var MIGRATIONS = []string{}
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package db_test
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
|
|
||||||
. "{{ .ModuleName }}/pkg/db"
|
. "{{ .ModuleName }}/pkg/db"
|
||||||
)
|
)
|
||||||
@@ -14,6 +14,6 @@ func init() {
|
|||||||
TestDB = MakeDB("tmp")
|
TestDB = MakeDB("tmp")
|
||||||
}
|
}
|
||||||
func MakeDB(dbName string) *DB {
|
func MakeDB(dbName string) *DB {
|
||||||
db := must.Get(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName)))
|
db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName)))
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DBConfig struct {
|
type DBConfig struct {
|
||||||
@@ -17,18 +15,32 @@ type DBConfig struct {
|
|||||||
// is_initialized bool
|
// is_initialized bool
|
||||||
|
|
||||||
// The SQL schema of the database under management
|
// The SQL schema of the database under management
|
||||||
sqlSchema *string
|
sql_schema *string
|
||||||
|
|
||||||
// Database starts at version 0. First migration brings us to version 1
|
// Database starts at version 0. First migration brings us to version 1
|
||||||
migrations *[]Migration
|
migrations *[]string
|
||||||
versionNumber uint
|
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{
|
return DBConfig{
|
||||||
sqlSchema: schema,
|
sql_schema: schema,
|
||||||
migrations: migrationsList,
|
migrations: migrationsList,
|
||||||
versionNumber: uint(len(*migrationsList)),
|
version_number: uint(len(*migrationsList)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +64,7 @@ func (c DBConfig) Create(path string) (*sqlx.DB, error) {
|
|||||||
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
||||||
return nil, fmt.Errorf("running pragma statements: %w", err)
|
return nil, fmt.Errorf("running pragma statements: %w", err)
|
||||||
}
|
}
|
||||||
if _, err = db.Exec(*c.sqlSchema); err != nil {
|
if _, err = db.Exec(*c.sql_schema); err != nil {
|
||||||
return nil, fmt.Errorf("creating schema: %w", err)
|
return nil, fmt.Errorf("creating schema: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,37 +91,50 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
|
|||||||
return fmt.Errorf("couldn't check database version: %w", err)
|
return fmt.Errorf("couldn't check database version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if version > c.versionNumber {
|
if version > c.version_number {
|
||||||
return VersionMismatchError{c.versionNumber, version}
|
return VersionMismatchError{c.version_number, version}
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.versionNumber > version {
|
if c.version_number > version {
|
||||||
fmt.Print(textutils.ColorYellow)
|
fmt.Print(ColorYellow)
|
||||||
fmt.Printf("================================================\n")
|
fmt.Printf("================================================\n")
|
||||||
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
|
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
|
||||||
c.versionNumber)
|
c.version_number)
|
||||||
fmt.Print(textutils.ColorReset)
|
fmt.Print(ColorReset)
|
||||||
|
c.UpgradeFromXToY(db, version, c.version_number)
|
||||||
for i := version; i < c.versionNumber; 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.versionNumber)
|
|
||||||
fmt.Print(textutils.ColorReset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
type VersionMismatchError struct {
|
||||||
EngineVersion uint
|
EngineVersion uint
|
||||||
DatabaseVersion uint
|
DatabaseVersion uint
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ import (
|
|||||||
func TestCreateAndConnectToDB(t *testing.T) {
|
func TestCreateAndConnectToDB(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
_schemaSQL, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
|
_schema_sql, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
schemaSQL := string(_schemaSQL)
|
schema_sql := string(_schema_sql)
|
||||||
migrations := []db.Migration{}
|
migrations := []string{}
|
||||||
|
|
||||||
config := db.Init(&schemaSQL, &migrations)
|
config := db.Init(&schema_sql, &migrations)
|
||||||
|
|
||||||
dataDir := "../../sample_data/data"
|
data_dir := "../../sample_data/data"
|
||||||
_ = os.MkdirAll(dataDir, os.FileMode(0o644))
|
_ = os.MkdirAll(data_dir, os.FileMode(0o644))
|
||||||
// Create then connect to a new empty DB
|
// Create then connect to a new empty DB
|
||||||
dbPath := filepath.Join(dataDir, "test.db")
|
dbPath := filepath.Join(data_dir, "test.db")
|
||||||
_ = os.Remove(dbPath) // Delete it if it exists
|
_ = os.Remove(dbPath) // Delete it if it exists
|
||||||
|
|
||||||
_, err = config.Create(dbPath)
|
_, err = config.Create(dbPath)
|
||||||
@@ -38,18 +38,18 @@ func TestVersionUpgrade(t *testing.T) {
|
|||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
// Create a little schema
|
// Create a little schema
|
||||||
initialSchema := `
|
initial_schema := `
|
||||||
create table items(rowid integer primary key);
|
create table items(rowid integer primary key);
|
||||||
create table db_version (version integer primary key) strict, without rowid;
|
create table db_version (version integer primary key) strict, without rowid;
|
||||||
insert into db_version values(0);
|
insert into db_version values(0);
|
||||||
`
|
`
|
||||||
migrations := []db.Migration{}
|
migrations := []string{}
|
||||||
config := db.Init(&initialSchema, &migrations)
|
config := db.Init(&initial_schema, &migrations)
|
||||||
|
|
||||||
connection, err := config.Create(":memory:")
|
connection, err := config.Create(":memory:")
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
|
|
||||||
getVersion := func(c *sqlx.DB) (ret int) {
|
get_version := func(c *sqlx.DB) (ret int) {
|
||||||
// TODO: this should be a function exposed by the `db` package itself
|
// TODO: this should be a function exposed by the `db` package itself
|
||||||
require.NoError(c.Get(&ret, "select version from db_version"))
|
require.NoError(c.Get(&ret, "select version from db_version"))
|
||||||
return
|
return
|
||||||
@@ -58,22 +58,22 @@ func TestVersionUpgrade(t *testing.T) {
|
|||||||
var items []int
|
var items []int
|
||||||
require.NoError(connection.Select(&items, "select * from items"))
|
require.NoError(connection.Select(&items, "select * from items"))
|
||||||
require.Len(items, 0)
|
require.Len(items, 0)
|
||||||
require.Equal(0, getVersion(connection))
|
require.Equal(0, get_version(connection))
|
||||||
|
|
||||||
// Create a migration to add a new Item
|
// Create a migration to add a new Item
|
||||||
migrations = append(migrations, db.Migration{ID: 1, SQL: "insert into items (rowid) values (1)"})
|
migrations = append(migrations, "insert into items (rowid) values (1)")
|
||||||
config = db.Init(&initialSchema, &migrations) // Reinitialize with the new migration
|
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 items2 []int
|
var items2 []int
|
||||||
require.NoError(connection.Select(&items2, "select * from items"))
|
require.NoError(connection.Select(&items2, "select * from items"))
|
||||||
require.Len(items2, 1)
|
require.Len(items2, 1)
|
||||||
require.Equal(1, getVersion(connection))
|
require.Equal(1, get_version(connection))
|
||||||
|
|
||||||
// Create a migration to add a new Item
|
// 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(&initialSchema, &migrations) // Reinitialize with the new migration
|
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 {
|
var items3 []struct {
|
||||||
ID uint64 `db:"rowid"`
|
ID uint64 `db:"rowid"`
|
||||||
@@ -82,5 +82,5 @@ func TestVersionUpgrade(t *testing.T) {
|
|||||||
require.NoError(connection.Select(&items3, "select * from items"))
|
require.NoError(connection.Select(&items3, "select * from items"))
|
||||||
require.Len(items2, 1)
|
require.Len(items2, 1)
|
||||||
assert.Equal(t, "asdf", items3[0].Name)
|
assert.Equal(t, "asdf", items3[0].Name)
|
||||||
require.Equal(2, getVersion(connection))
|
require.Equal(2, get_version(connection))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ var (
|
|||||||
ErrItemIsDeleted = errors.New("item is deleted")
|
ErrItemIsDeleted = errors.New("item is deleted")
|
||||||
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
|
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
|
||||||
ErrDatabaseAlreadyExists = errors.New("target already exists")
|
ErrDatabaseAlreadyExists = errors.New("target already exists")
|
||||||
|
|
||||||
ErrMigrationOrder = errors.New("migration in wrong order")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ForeignKey interface {
|
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 isFkEnabled bool
|
|
||||||
require.NoError(t, conn.Get(&isFkEnabled, `pragma foreign_keys`))
|
|
||||||
assert.True(t, isFkEnabled)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
18
pkg/flowutils/flowutils.go
Normal file
18
pkg/flowutils/flowutils.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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(ok bool, msg string, args ...any) {
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Errorf(msg, args...)) //nolint:err113 // not a returned error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Close(closer io.Closer) {
|
|
||||||
Do(closer.Close())
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,15 @@ import (
|
|||||||
|
|
||||||
// Column represents a single column in a table.
|
// Column represents a single column in a table.
|
||||||
type Column struct {
|
type Column struct {
|
||||||
TableName string `db:"table_name"`
|
// TableName is the name of the SQLite table this column belongs to.
|
||||||
Name string `db:"column_name"`
|
TableName string `db:"table_name"`
|
||||||
Type string `db:"column_type"`
|
|
||||||
|
// 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"`
|
IsNotNull bool `db:"notnull"`
|
||||||
HasDefaultValue bool `db:"has_default_value"`
|
HasDefaultValue bool `db:"has_default_value"`
|
||||||
DefaultValue string `db:"dflt_value"`
|
DefaultValue string `db:"dflt_value"`
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
package schema
|
package schema
|
||||||
|
|
||||||
type IndexOrigin string
|
|
||||||
|
|
||||||
const (
|
|
||||||
IndexOriginCreateIndex = IndexOrigin("c")
|
|
||||||
IndexOriginUniqueConstraint = IndexOrigin("u")
|
|
||||||
IndexOriginPrimaryKey = IndexOrigin("pk")
|
|
||||||
)
|
|
||||||
|
|
||||||
type Index struct {
|
type Index struct {
|
||||||
Name string `db:"index_name"`
|
Name string `db:"index_name"`
|
||||||
TableName string `db:"table_name"`
|
TableName string `db:"table_name"`
|
||||||
Columns []string
|
Columns []string
|
||||||
IsUnique bool `db:"is_unique"`
|
IsUnique bool `db:"is_unique"`
|
||||||
Origin IndexOrigin `db:"origin"`
|
|
||||||
// TODO: `where ...` for partial indexes
|
// TODO: `where ...` for partial indexes
|
||||||
// TODO: identify columns that are expressions
|
// TODO: identify columns that are expressions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ var Checks = []Check{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if target column is a primary key
|
// Check if target column is a primary key
|
||||||
for _, targetCol := range s.Tables[column.ForeignKeyTargetTable].Columns {
|
for _, target_col := range s.Tables[column.ForeignKeyTargetTable].Columns {
|
||||||
if targetCol.Name == column.ForeignKeyTargetColumn && targetCol.IsPrimaryKey {
|
if target_col.Name == column.ForeignKeyTargetColumn && target_col.IsPrimaryKey {
|
||||||
continue fk_loop
|
continue fk_loop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,8 +125,8 @@ var Checks = []Check{
|
|||||||
if idx.TableName != column.ForeignKeyTargetTable {
|
if idx.TableName != column.ForeignKeyTargetTable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, idxColName := range idx.Columns {
|
for _, idx_col_name := range idx.Columns {
|
||||||
if column.ForeignKeyTargetColumn == idxColName {
|
if column.ForeignKeyTargetColumn == idx_col_name {
|
||||||
continue fk_loop
|
continue fk_loop
|
||||||
}
|
}
|
||||||
break // Only look at 1st column
|
break // Only look at 1st column
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestFailureCases(t *testing.T) {
|
func TestFailureCases(t *testing.T) {
|
||||||
testCases := []struct {
|
test_cases := []struct {
|
||||||
sqlFile string
|
sqlFile string
|
||||||
expectedFailures []string
|
expectedFailures []string
|
||||||
}{
|
}{
|
||||||
@@ -34,24 +34,24 @@ func TestFailureCases(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, test_case := range test_cases {
|
||||||
t.Run(testCase.sqlFile, func(t *testing.T) {
|
t.Run(test_case.sqlFile, func(t *testing.T) {
|
||||||
schema, err := schema.SchemaFromSQLFile(testCase.sqlFile)
|
schema, err := schema.SchemaFromSQLFile(test_case.sqlFile)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, check := range lint.Checks {
|
for _, check := range lint.Checks {
|
||||||
results := check.Execute(schema)
|
results := check.Execute(schema)
|
||||||
|
|
||||||
isFailure := len(results) > 0
|
is_failure := len(results) > 0
|
||||||
isFailureExpected := slices.Contains(testCase.expectedFailures, check.Name)
|
is_failure_expected := slices.Contains(test_case.expectedFailures, check.Name)
|
||||||
|
|
||||||
if isFailure != isFailureExpected {
|
if is_failure != is_failure_expected {
|
||||||
if isFailureExpected {
|
if is_failure_expected {
|
||||||
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, testCase.sqlFile)
|
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, test_case.sqlFile)
|
||||||
} else {
|
} else {
|
||||||
t.Errorf("Expected check '%s' to pass, but it failed: %s (%q.%q)",
|
t.Errorf("Expected check '%s' to pass, but it failed: %s (%q.%q)",
|
||||||
check.Name,
|
check.Name,
|
||||||
testCase.sqlFile,
|
test_case.sqlFile,
|
||||||
results[0].TableName, results[0].ColumnName,
|
results[0].TableName, results[0].ColumnName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,14 +50,14 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
|||||||
db1Schema := schema.SchemaFromDB(db1)
|
db1Schema := schema.SchemaFromDB(db1)
|
||||||
|
|
||||||
t.Run("migrate in 1 step", func(t *testing.T) {
|
t.Run("migrate in 1 step", func(t *testing.T) {
|
||||||
migration := db.Migration{ID: 1, SQL: `
|
migration := `
|
||||||
create table t2 (
|
create table t2 (
|
||||||
rowid integer primary key
|
rowid integer primary key
|
||||||
);
|
);
|
||||||
alter table t1 add column data3 integer;
|
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:"))
|
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||||
db2Schema := schema.SchemaFromDB(db2)
|
db2Schema := schema.SchemaFromDB(db2)
|
||||||
|
|
||||||
@@ -67,17 +67,17 @@ func TestVerifyCorrectMigration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("migrate in 2 steps", func(t *testing.T) {
|
t.Run("migrate in 2 steps", func(t *testing.T) {
|
||||||
migration1 := db.Migration{ID: 1, SQL: `
|
migration1 := `
|
||||||
create table t2 (
|
create table t2 (
|
||||||
rowid integer primary key
|
rowid integer primary key
|
||||||
);
|
);
|
||||||
`}
|
`
|
||||||
migration2 := db.Migration{ID: 2, SQL: `
|
migration2 := `
|
||||||
alter table t1 add column data3 integer;
|
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:"))
|
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||||
db2Schema := schema.SchemaFromDB(db2)
|
db2Schema := schema.SchemaFromDB(db2)
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ func TestIncorrectMigrations(t *testing.T) {
|
|||||||
db1Schema := schema.SchemaFromDB(db1)
|
db1Schema := schema.SchemaFromDB(db1)
|
||||||
|
|
||||||
t.Run("missing migration", func(t *testing.T) {
|
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:"))
|
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||||
db2Schema := schema.SchemaFromDB(db2)
|
db2Schema := schema.SchemaFromDB(db2)
|
||||||
|
|
||||||
@@ -109,14 +109,12 @@ func TestIncorrectMigrations(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("incomplete migration", func(t *testing.T) {
|
t.Run("incomplete migration", func(t *testing.T) {
|
||||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
db2Config := db.Init(&baseSchema, &[]string{`
|
||||||
{ID: 1, SQL: `
|
|
||||||
create table t2 (
|
create table t2 (
|
||||||
rowid integer primary key
|
rowid integer primary key
|
||||||
);
|
);
|
||||||
`},
|
`})
|
||||||
})
|
db2 := flowutils.Must(db2Config.Create(":memory:"))
|
||||||
db2 := must.Get(db2Config.Create(":memory:"))
|
|
||||||
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||||
db2Schema := schema.SchemaFromDB(db2)
|
db2Schema := schema.SchemaFromDB(db2)
|
||||||
|
|
||||||
@@ -132,15 +130,13 @@ func TestIncorrectMigrations(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("incorrect migration (wrong data type)", func(t *testing.T) {
|
t.Run("incorrect migration (wrong data type)", func(t *testing.T) {
|
||||||
db2Config := db.Init(&baseSchema, &[]db.Migration{
|
db2Config := db.Init(&baseSchema, &[]string{`
|
||||||
{ID: 1, SQL: `
|
create table t2 (
|
||||||
create table t2 (
|
rowid integer primary key
|
||||||
rowid integer primary key
|
);
|
||||||
);
|
alter table t1 add column data3 text;
|
||||||
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))
|
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
|
||||||
db2Schema := schema.SchemaFromDB(db2)
|
db2Schema := schema.SchemaFromDB(db2)
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
|
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed views.sql
|
//go:embed views.sql
|
||||||
var createViews string
|
var create_views string
|
||||||
|
|
||||||
// InitDB creates an in-memory DB from a given schema string.
|
// InitDB creates an in-memory DB from a given schema string.
|
||||||
func InitDB(sqlSchema string) *sqlx.DB {
|
func InitDB(sql_schema string) *sqlx.DB {
|
||||||
db := sqlx.MustOpen("sqlite3", ":memory:")
|
db := sqlx.MustOpen("sqlite3", ":memory:")
|
||||||
db.MustExec(sqlSchema)
|
db.MustExec(sql_schema)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,23 +35,23 @@ func SchemaFromSQLFile(filepath string) (Schema, error) {
|
|||||||
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
|
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
|
||||||
func SchemaFromDB(db *sqlx.DB) Schema {
|
func SchemaFromDB(db *sqlx.DB) Schema {
|
||||||
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
|
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
|
||||||
db.MustExec(createViews)
|
db.MustExec(create_views)
|
||||||
|
|
||||||
var tables []Table
|
var tables []Table
|
||||||
must.Do(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
|
PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
|
||||||
for _, tbl := range tables {
|
for _, tbl := range tables {
|
||||||
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
|
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
|
||||||
tbl.TypeIDName = tbl.GoTypeName + "ID"
|
tbl.TypeIDName = tbl.GoTypeName + "ID"
|
||||||
tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
|
tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
|
||||||
|
|
||||||
must.Do(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
|
PanicIf(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
|
||||||
ret.Tables[tbl.TableName] = tbl
|
ret.Tables[tbl.TableName] = tbl
|
||||||
}
|
}
|
||||||
|
|
||||||
var indexes []Index
|
var indexes []Index
|
||||||
must.Do(db.Select(&indexes, `select index_name, table_name, is_unique, origin from indexes`))
|
PanicIf(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
|
||||||
for _, idx := range 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))
|
PanicIf(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
|
||||||
ret.Indexes[idx.Name] = idx
|
ret.Indexes[idx.Name] = idx
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import (
|
|||||||
|
|
||||||
func TestParseSchema(t *testing.T) {
|
func TestParseSchema(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
schemaSQL, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
|
schema_sql, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
db := schema.InitDB(string(schemaSQL))
|
db := schema.InitDB(string(schema_sql))
|
||||||
schema := schema.SchemaFromDB(db)
|
schema := schema.SchemaFromDB(db)
|
||||||
expectedTbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
|
expected_tbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
|
||||||
for _, tblName := range expectedTbls {
|
for _, tbl_name := range expected_tbls {
|
||||||
_, isOk := schema.Tables[tblName]
|
_, is_ok := schema.Tables[tbl_name]
|
||||||
assert.True(isOk)
|
assert.True(is_ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
foods := schema.Tables["foods"]
|
foods := schema.Tables["foods"]
|
||||||
@@ -57,23 +57,23 @@ func TestParseSchema(t *testing.T) {
|
|||||||
// -------
|
// -------
|
||||||
|
|
||||||
// Unique index
|
// Unique index
|
||||||
unitsNameIdx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
|
units_name_idx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
|
||||||
require.True(t, isOk)
|
require.True(t, isOk)
|
||||||
assert.True(unitsNameIdx.IsUnique)
|
assert.True(units_name_idx.IsUnique)
|
||||||
assert.Equal("units", unitsNameIdx.TableName)
|
assert.Equal("units", units_name_idx.TableName)
|
||||||
assert.Equal([]string{"name"}, unitsNameIdx.Columns)
|
assert.Equal([]string{"name"}, units_name_idx.Columns)
|
||||||
|
|
||||||
// Non-unique, declared index
|
// Non-unique, declared index
|
||||||
foodsProteinIdx, isOk := schema.Indexes["foods_protein"]
|
foods_protein_idx, isOk := schema.Indexes["foods_protein"]
|
||||||
require.True(t, isOk)
|
require.True(t, isOk)
|
||||||
assert.False(foodsProteinIdx.IsUnique)
|
assert.False(foods_protein_idx.IsUnique)
|
||||||
assert.Equal("foods", foodsProteinIdx.TableName)
|
assert.Equal("foods", foods_protein_idx.TableName)
|
||||||
assert.Equal([]string{"protein"}, foodsProteinIdx.Columns)
|
assert.Equal([]string{"protein"}, foods_protein_idx.Columns)
|
||||||
|
|
||||||
// Multi-column index
|
// Multi-column index
|
||||||
ingrSeqIdx, isOk := schema.Indexes["sqlite_autoindex_ingredients_1"]
|
ingr_seq_idx, isOk := schema.Indexes["sqlite_autoindex_ingredients_1"]
|
||||||
require.True(t, isOk)
|
require.True(t, isOk)
|
||||||
assert.True(ingrSeqIdx.IsUnique)
|
assert.True(ingr_seq_idx.IsUnique)
|
||||||
assert.Equal("ingredients", ingrSeqIdx.TableName)
|
assert.Equal("ingredients", ingr_seq_idx.TableName)
|
||||||
assert.Equal([]string{"in_recipe_id", "list_order"}, ingrSeqIdx.Columns)
|
assert.Equal([]string{"in_recipe_id", "list_order"}, ingr_seq_idx.Columns)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package schema
|
package schema
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Table is a single SQLite table.
|
// Table is a single SQLite table.
|
||||||
@@ -62,35 +67,10 @@ func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSoftDelete reports whether this table supports soft-deletion, i.e., whether it has both
|
func (t Table) GetCodeTableValues(db *sqlx.DB) (ret []string) {
|
||||||
// an "is_deleted" and a "deleted_at" column.
|
if !slices.ContainsFunc(t.Columns, func(c Column) bool { return c.Name == "name" }) {
|
||||||
func (t Table) HasSoftDelete() bool {
|
panic("not a code table")
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
flowutils.PanicIf(db.Select(&ret, fmt.Sprintf("select name from %s", t.TableName)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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,8 +27,7 @@ create temporary view columns as
|
|||||||
create temporary view indexes as
|
create temporary view indexes as
|
||||||
select idx.name as index_name,
|
select idx.name as index_name,
|
||||||
tables.name as table_name,
|
tables.name as table_name,
|
||||||
idx."unique" as is_unique,
|
idx."unique" as is_unique
|
||||||
idx.origin as origin
|
|
||||||
from tables
|
from tables
|
||||||
join pragma_index_list(tables.name) idx;
|
join pragma_index_list(tables.name) idx;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ func SnakeToCamel(s string) string {
|
|||||||
return strings.Join(parts, "")
|
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 {
|
func CamelToPascal(s string) string {
|
||||||
return strings.ToLower(s)[0:1] + s[1:]
|
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