2 Commits

Author SHA1 Message Date
01e37a0e07 TMP: codetables
Some checks failed
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 9s
2026-03-19 23:49:10 -07:00
f8664ed514 schema: split 'column', 'index' and 'schema' into separate files from 'table' 2026-03-19 23:46:59 -07:00
53 changed files with 803 additions and 1886 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
name: CI
on: [push, workflow_dispatch]
on: [push]
jobs:
# These steps build the `gas` docker image.

1
.gitignore vendored
View File

@@ -4,4 +4,3 @@ sample_data/data
.cmd-old/
pkg/.testapp
.claude
bin

View File

@@ -20,20 +20,7 @@ linters:
- whitespace
- wrapcheck
# Shared custom linters from ../go-lint.
- ifinit
- boolnames
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:
rules:
main:
@@ -67,16 +54,17 @@ linters:
require-specific: true
allow-unused: false
staticcheck:
go: "1.24"
checks:
- all
- -ST1000 # Re-enable this once we have docstrings
- -ST1003 # I like snake_case
- -ST1013 # HTTP status codes are shorter and more readable than names
wrapcheck:
report-internal-errors: false
extra-ignore-sigs:
- json.Marshal(
dot-import-whitelist:
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
exclusions:
generated: lax # Don't lint generated files
paths:
formatters:
enable:
@@ -92,6 +80,7 @@ formatters:
simplify: true
exclusions:
generated: lax
paths:
run:
build-tags:

View File

@@ -5,8 +5,6 @@ import (
"os"
"github.com/spf13/cobra"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
)
const (
@@ -16,19 +14,16 @@ const (
)
func main() {
rootCmd := &cobra.Command{
root_cmd := &cobra.Command{
Use: "gas",
Version: version.String(),
SilenceErrors: true,
SilenceUsage: true,
}
// Print a bare version string ("v0.0.4"), rather than cobra's "gas version v0.0.4".
rootCmd.SetVersionTemplate("{{.Version}}\n")
rootCmd.AddCommand(sqliteLint)
rootCmd.AddCommand(cmdInit)
rootCmd.AddCommand(generateModel)
rootCmd.AddCommand(cmdVersion)
if err := rootCmd.Execute(); err != nil {
root_cmd.AddCommand(sqlite_lint)
root_cmd.AddCommand(cmd_init)
root_cmd.AddCommand(generate_model)
root_cmd.AddCommand(generate_codetable_type)
if err := root_cmd.Execute(); err != nil {
fmt.Println(RED + err.Error() + RESET)
os.Exit(1)
}

View 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")
}

View File

@@ -6,40 +6,39 @@ import (
"go/ast"
"go/token"
"os"
"slices"
"github.com/spf13/cobra"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
var ErrNoSuchTable = errors.New("no such table")
var generateModel = &cobra.Command{
var generate_model = &cobra.Command{
Use: "generate <model_name>",
Short: "Generate a model type",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := must.Get(cmd.Flags().GetString("schema"))
modname := must.Get(cmd.Flags().GetString("modname"))
path := Must(cmd.Flags().GetString("schema"))
modname := Must(cmd.Flags().GetString("modname"))
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading path %s: %w", path, err)
}
db := schema.InitDB(string(sql))
sch := schema.SchemaFromDB(db)
table, isOk := sch.Tables[args[0]]
schema := schema.SchemaFromDB(db)
table, isOk := schema.Tables[args[0]]
if !isOk {
return ErrNoSuchTable
}
if must.Get(cmd.Flags().GetBool("test")) {
file2 := modelgenerate.GenerateModelTestAST(table, sch, modname)
must.Do(modelgenerate.FprintWithComments(os.Stdout, file2))
if Must(cmd.Flags().GetBool("test")) {
file2 := modelgenerate.GenerateModelTestAST(table, schema, modname)
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file2))
} else {
decls := []ast.Decl{
&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"`},
},
&ast.ImportSpec{
Name: ast.NewIdent("."),
Path: &ast.BasicLit{
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.GenerateDeleteItemFunc(table),
)
if table.HasSoftDelete() {
decls = append(decls, modelgenerate.GenerateSoftDeleteItemFunc(table))
}
if table.IsWithoutRowid {
decls = append(decls,
modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()),
@@ -81,27 +78,13 @@ var generateModel = &cobra.Command{
modelgenerate.GenerateGetItemByIDFunc(table),
)
}
for _, index := range sch.Indexes {
for _, index := range schema.Indexes {
if index.TableName != table.TableName {
// Skip indexes on other tables
continue
}
if slices.Contains(index.Columns, "") {
// Skip expression indexes; there's no way to resolve an expression to a real column
continue
}
if index.Origin == "pk" && table.IsWithoutRowid {
// Already generated above, as the primary-key getter
continue
}
cols := make([]schema.Column, len(index.Columns))
for i, colName := range index.Columns {
cols[i] = table.GetColumnByName(colName)
}
if index.IsUnique {
decls = append(decls, modelgenerate.GenerateGetItemBy(table, cols))
} else {
decls = append(decls, modelgenerate.GenerateGetItemsBy(table, cols))
if index.IsUnique && len(index.Columns) == 1 {
decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
}
}
decls = append(decls, modelgenerate.GenerateGetAllItemsFunc(table))
@@ -111,15 +94,16 @@ var generateModel = &cobra.Command{
Decls: decls,
}
must.Do(modelgenerate.FprintWithComments(os.Stdout, file))
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
}
return nil
},
}
// DUPE: generate-flags
func init() {
generateModel.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)")
generateModel.Flags().Bool("test", false, "Generate test file instead of regular file")
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
generate_model.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
generate_model.Flags().Bool("test", false, "Generate test file instead of regular file")
}

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"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]",
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.",
@@ -23,15 +23,15 @@ var cmdInit = &cobra.Command{
var target string
if len(args) != 0 {
target = args[0]
must.Do(os.MkdirAll(target, 0o755))
must.Do(os.Chdir(target))
PanicIf(os.MkdirAll(target, 0o755))
PanicIf(os.Chdir(target))
} else {
// Default to current directory (".")
target = must.Get(os.Getwd())
target = Must(os.Getwd())
}
// Get all the config values
getVal := func(prompt string, val *string) {
get_val := func(prompt string, val *string) {
fmt.Printf("%s (%q): ", prompt, *val)
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
@@ -40,31 +40,31 @@ var cmdInit = &cobra.Command{
*val = input
}
}
pkgOpts := codegen.PkgOpts{
ModuleName: must.Get(cmd.Flags().GetString("module")),
DBFilename: must.Get(cmd.Flags().GetString("db")),
BinaryName: must.Get(cmd.Flags().GetString("binary")),
pkg_opts := codegen.PkgOpts{
ModuleName: Must(cmd.Flags().GetString("module")),
DBFilename: Must(cmd.Flags().GetString("db")),
BinaryName: Must(cmd.Flags().GetString("binary")),
}
if pkgOpts.ModuleName == "" {
pkgOpts.ModuleName = filepath.Base(target)
getVal("module name", &pkgOpts.ModuleName)
if pkg_opts.ModuleName == "" {
pkg_opts.ModuleName = filepath.Base(target)
get_val("module name", &pkg_opts.ModuleName)
}
if pkgOpts.DBFilename == "" {
pkgOpts.DBFilename = pkgOpts.ModuleName + ".db"
getVal("db name", &pkgOpts.DBFilename)
if pkg_opts.DBFilename == "" {
pkg_opts.DBFilename = pkg_opts.ModuleName + ".db"
get_val("db name", &pkg_opts.DBFilename)
}
if pkgOpts.BinaryName == "" {
pkgOpts.BinaryName = pkgOpts.ModuleName
getVal("binary name", &pkgOpts.BinaryName)
if pkg_opts.BinaryName == "" {
pkg_opts.BinaryName = pkg_opts.ModuleName
get_val("binary name", &pkg_opts.BinaryName)
}
// Run project initialization
codegen.InitPkg(pkgOpts)
codegen.InitPkg(pkg_opts)
},
}
func init() {
cmdInit.Flags().String("module", "", "Module name")
cmdInit.Flags().String("db", "", "Database filename")
cmdInit.Flags().String("binary", "", "Binary name")
cmd_init.Flags().String("module", "", "Module name")
cmd_init.Flags().String("db", "", "Database filename")
cmd_init.Flags().String("binary", "", "Binary name")
}

View File

@@ -12,7 +12,7 @@ import (
"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>",
Short: "Lint a SQLite schema",
@@ -32,18 +32,18 @@ var sqliteLint = &cobra.Command{
os.Exit(1)
}
isFailure := false
is_failure := false
// Execute each check against the database
for _, check := range lint.Checks {
// Checks can be disabled via Github config / environment variables
if !isCheckEnabled(check) {
if !is_check_enabled(check) {
continue
}
results := check.Execute(schema)
// If there are results, print them as lint errors
if len(results) > 0 {
isFailure = true
is_failure = true
fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name)
for _, result := range results {
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)
}
}
if isFailure {
if is_failure {
fmt.Println(RED + "Errors found" + RESET)
os.Exit(1)
}
@@ -61,16 +61,16 @@ var sqliteLint = &cobra.Command{
// github_actions_input_env_var converts an input name to the corresponding
// 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
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name)
return "INPUT_" + strings.ToUpper(normalized)
}
// Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check
func isCheckEnabled(c lint.Check) bool {
val, isSet := os.LookupEnv(githubActionsInputEnvVar(c.Name))
if !isSet {
func is_check_enabled(c lint.Check) bool {
val, is_set := os.LookupEnv(github_actions_input_env_var(c.Name))
if !is_set {
// Enable all checks by default
return true
}

View File

@@ -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())
},
}

View File

@@ -1,6 +1,13 @@
TODO: soft-deletion
- enable soft-deletion if table has `is_deleted` and `deleted_at` fields
- if soft delete is enabled, DeleteXyz should do update (not delete) and set is_deleted and deleted_at
- ...and DeleteXyz should have pointer receiver for soft-delete
- GetXyzByID should include `ErrItemIsDeleted` if item is soft-deleted
TODO: migration-structs
- Right now, migrations are strings. Could be a struct with "name", "up" and "down" fields
- Adding a "down" operation enables handling newer DB versions with "down instead of error-out" for development (perhaps a flag)
IDEA: migrations-table
- Store migrations in a table. This makes the schema more self-documenting.
- Possible schema: name, sql_up, sql_down, hash (computed from those fields plus previous migration hash)
@@ -15,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
- An update that violates an FK constraint will panic instead of returning an error
TODO: codegen `without rowid` tables properly
TODO: generated test file inclues global test DB setup, which is wrong
TODO: join-tables
- handle codegen for without rowid tables properly
- add "get all X for Y"
- Example: `chat_pinned_messages`
TODO: create a Getter function for each unique index
TODO: consider CreateDB and CreateMemoryDB
TODO: generate tests for the remaining CRUD, try to hit ~100% default test coverage
TODO: test reporter
- coverage: https://x.com/wispem_wantex/status/2009799380648505563
- Jest shows for each file: % of lines, functions, branches and statements covered
- Go reporter should show:
- group coverage stats by either function, file or package
- count by statements, branches or functions
- Show both raw count and percentage
- list which lines / branches / functions were missed
- pretty (terminal) format or JSON format
TODO: foreign key to compound-FK table
- sample: booru_post_tag_votes
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"

View File

@@ -29,12 +29,6 @@
- `ops` directory
### Reinventing the wheel
Wheels aren't difficult to invent; the concept is very simple. However, there's many different types, shapes and sizes of wheels. The wheel on a shopping cart is different from the wheel on a bicycle, a car, a tractor, a train, a spool of thread, etc.
Reinventing the wheel is good. It's good to use things that are specifically built for your purpose, with exactly the set of features and affordances you need. Using a wheel that was made by someone else for a different purpose can be faster at first, but it's likely that their needs were different from yours.
### Borrowed from Ruby On Rails
- Convention Over Configuration

View File

@@ -1,4 +1 @@
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

View File

@@ -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

View File

@@ -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"

View File

@@ -1,10 +1,3 @@
#!/bin/sh
# Stamp the version into the binary. `git describe` fails when this isn't a git checkout (e.g. a
# source export with no .git dir); in that case leave it empty, and pkg/version falls back to the
# build info that the Go toolchain embeds.
VERSION=$(git describe --tags --always --dirty 2>/dev/null)
go build -tags fts5 \
-ldflags "-X git.offline-twitter.com/offline-labs/gas-stack/pkg/version.Version=$VERSION" \
-o gas ./cmd
go build -tags fts5 -o gas ./cmd

View File

@@ -1,13 +1,13 @@
# 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
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
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
COPY etc/group /etc/group

View File

@@ -10,5 +10,4 @@ sudo docker run --rm -it \
-v /memory:/memory \
--workdir /code \
--net host \
--name gas \
gas

View File

@@ -59,7 +59,6 @@ EOF
$gas generate items > pkg/db/item.go
$gas generate items --test > pkg/db/item_test.go
$gas generate item_to_item > pkg/db/item_to_item.go
# $gas generate item_to_item --test > pkg/db/item_to_item_test.go
go mod tidy
# Run the tests

View File

@@ -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 $@

View File

@@ -1,3 +0,0 @@
#!/bin/sh
go test -tags fts5 ./... $@

View File

@@ -27,18 +27,10 @@ const (
// we don't have to do that.
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 {
return &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Get")},
Args: []ast.Expr{inner},
}
}
// doCall wraps a call expression in must.Do(...), producing AST for must.Do(inner).
func doCall(inner ast.Expr) *ast.CallExpr {
return &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Do")},
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{inner},
}
}
@@ -81,7 +73,7 @@ func FprintWithComments(w io.Writer, file *ast.File) error {
fset = token.NewFileSet()
parsed, err := parser.ParseFile(fset, "", buf.Bytes(), parser.ParseComments)
if err != nil {
return fmt.Errorf("re-parsing pretty-print: %w\n\n%s", err, buf.String())
return fmt.Errorf("re-parsing pretty-print: %w", err)
}
// Parallel walk: apply TrailingComments from the side map.

View 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,
},
},
},
},
},
}
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"go/ast"
"go/token"
"slices"
"strings"
"github.com/jinzhu/inflection"
@@ -24,7 +23,7 @@ var (
)
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.
@@ -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
// was affected by the preceding statement, e.g.:
//
// must.Be(must.Get(result.RowsAffected()) == 1, "%w: Food ID=%d", ErrNotInDB, f.ID)
//
// For "without rowid" tables, the message includes the table's primary key column(s) instead of ID.
func MustBeRowsAffected(tbl schema.Table) *ast.ExprStmt {
pkParts := []string{}
pkArgs := []ast.Expr{}
if tbl.IsWithoutRowid {
for _, col := range tbl.PrimaryKeyColumns() {
verb := "%v"
if col.Type == "integer" || col.Type == "int" || col.IsNonCodeTableForeignKey() {
verb = "%d"
}
pkParts = append(pkParts, fmt.Sprintf("%s=%s", col.Name, verb))
pkArgs = append(pkArgs, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(col.GoFieldName())})
}
} else {
pkParts = append(pkParts, "ID=%d")
pkArgs = append(pkArgs, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")})
}
msg := fmt.Sprintf("%%w: %s %s", tbl.GoTypeName, strings.Join(pkParts, ", "))
args := append([]ast.Expr{
&ast.BinaryExpr{
func PanicIfRowsAffected(tbl schema.Table) *ast.IfStmt {
return &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
Args: []ast.Expr{},
}),
Op: token.EQL,
Op: token.NEQ,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", msg)},
ast.NewIdent("ErrNotInDB"),
}, pkArgs...)
return &ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("must"), Sel: ast.NewIdent("Be")},
Args: args,
}}
Body: &ast.BlockStmt{List: []ast.Stmt{
&ast.ExprStmt{X: &ast.CallExpr{Fun: ast.NewIdent("panic"), Args: []ast.Expr{ast.NewIdent(tbl.VarName)}}},
}},
}
}
// ---------------
@@ -190,92 +161,79 @@ func buildFKCheckLambda(tbl schema.Table) (*ast.AssignStmt, bool) {
structFieldName := col.GoFieldName()
structField := &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(structFieldName)}
ret = append(ret, func() ast.Stmt {
// Wrap nullable FKs in "if a.val != 0 { ... }"
wrap := func(input ast.Stmt) ast.Stmt {
if col.IsNullableForeignKey() {
return &ast.IfStmt{
Cond: &ast.BinaryExpr{X: structField, Op: token.NEQ, Y: &ast.BasicLit{Kind: token.INT, Value: "0"}},
Body: &ast.BlockStmt{List: []ast.Stmt{input}},
}
} else {
return input
}
}
if col.IsNonCodeTableForeignKey() {
// Real foreign key; look up referent by ID to see if it exists
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},
},
if col.IsNonCodeTableForeignKey() {
// Real foreign key; look up referent by ID to see if it exists
ret = append(ret, &ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent(getByIDFuncName(col.ForeignKeyTargetTable))},
Args: []ast.Expr{structField},
},
},
Cond: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
structField,
},
},
Cond: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Args: []ast.Expr{ast.NewIdent("err"), ast.NewIdent("ErrNotInDB")},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
structField,
},
},
},
},
},
})
} else {
// Code table value. Query the table to see if it exists
return wrap(&ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
Args: []ast.Expr{
&ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)},
structField,
},
},
})
} else {
// Code table value. Query the table to see if it exists
ret = append(ret, &ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("err")},
Tok: token.ASSIGN,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Get")},
Args: []ast.Expr{
&ast.CallExpr{Fun: ast.NewIdent("new"), Args: []ast.Expr{ast.NewIdent("int")}},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`select 1 from %s where rowid = ?`", col.ForeignKeyTargetTable)},
structField,
},
},
},
Cond: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
structField,
},
},
Cond: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")},
Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CallExpr{
Fun: ast.NewIdent("NewForeignKeyError"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", structFieldName)},
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", col.ForeignKeyTargetTable)},
structField,
},
},
},
},
},
})
}
}())
},
})
}
}
// final return 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()
// Assemble SQL "insert" string
// Assemble data for building SQL "insert" and "update" strings
for _, col := range tbl.Columns {
if col.Name == "rowid" {
continue
@@ -308,20 +266,19 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
val = fmt.Sprintf("nullif(%s, 0)", val)
}
insertVals = append(insertVals, val)
// created_at should not be updated after creation
if col.Name == "created_at" && hasCreatedAt {
continue
}
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
updatePairs = append(updatePairs, col.Name+"="+val)
}
}
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
tbl.TableName,
strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "),
)
// ...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",
tbl.TableName,
strings.Join(updatePairs, ",\n\t\t "),
@@ -363,7 +320,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
},
}
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{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
@@ -429,26 +386,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}
if tbl.IsWithoutRowid {
if hasCreatedAt {
// Auto-timestamps: created_at. Don't overwrite existing timestamps (e.g., data import / migrations)
ret = append(ret, &ast.IfStmt{
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")},
Sel: ast.NewIdent("IsZero"),
}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
},
},
},
})
}
ret = append(ret, namedExecStmt(upsertStmt)...)
ret = append(ret, MustBeRowsAffected(tbl))
ret = append(ret, PanicIfRowsAffected(tbl))
} else {
// if item.ID == 0 {...} else {...}
ret = append(ret, &ast.IfStmt{
@@ -464,21 +403,10 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
ret1 := []ast.Stmt{Comment("Do create")}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &ast.IfStmt{
// Don't overwrite existing timestamps. This is useful for various reasons, e.g., data import / migrations
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")},
Sel: ast.NewIdent("IsZero"),
}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
},
},
},
ret1 = append(ret1, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
return append(ret1, namedExecStmt(insertStmt)...)
@@ -502,7 +430,7 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
[]ast.Stmt{Comment("Do update")},
append(
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{
Doc: &ast.CommentGroup{List: docLines},
Doc: &ast.CommentGroup{List: []*ast.Comment{
{Text: fmt.Sprintf("// Save%s creates or updates a %s in the database.", tbl.GoTypeName, tbl.GoTypeName)},
{Text: "// If the item doesn't exist (has no ID set), it will create it; otherwise it will do an update."},
}},
Recv: dbRecv,
Name: ast.NewIdent("Save" + tbl.GoTypeName),
Type: &ast.FuncType{
@@ -546,50 +469,15 @@ func getByIDFuncName(tblname string) string {
return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
}
// paramNamesFor picks parameter identifiers for a `Get...By()` function's columns.
// A single column always uses the short GoVarName() form (e.g. "name"). For multiple
// columns, GoVarName() is preferred for readability, but if two or more of the given
// columns would produce the same short name (e.g. two foreign keys that both abbreviate
// to "uID"), the longer, unambiguous LongGoVarName() form is used for all of them instead,
// since a collision there would produce invalid (duplicate-parameter) Go code.
func paramNamesFor(cols []schema.Column) []string {
names := make([]string, len(cols))
if len(cols) == 1 {
names[0] = cols[0].GoVarName()
return names
}
hasConflict := false
for i, c := range cols {
names[i] = c.GoVarName()
if slices.Contains(names[:i], names[i]) {
hasConflict = true
break
}
}
if !hasConflict {
return names
}
for i, c := range cols {
names[i] = c.LongGoVarName()
}
return names
}
// GenerateGetItemBy produces an AST for a `GetXyzByCol1AndCol2...()` function that returns
// the single item matching an exact-match lookup over the given columns (or ErrNotInDB).
// Used for unique indexes (single- or multi-column) and for "without rowid" tables' primary keys.
func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
paramNames := paramNamesFor(cols)
colNames := []string{}
funcNameSuffix := []string{}
funcParams := &ast.FieldList{List: []*ast.Field{}}
sqlParams := []ast.Expr{}
for i, col := range cols {
funcParam := ast.NewIdent(paramNames[i])
for _, col := range cols {
funcParam := ast.NewIdent(col.LongGoVarName())
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())
sqlParams = append(sqlParams, funcParam)
}
@@ -601,7 +489,7 @@ func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
Y: SQLFieldsConstIdent(tbl),
},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`\n\t from %s\n\t where %s\n\t`", tbl.TableName, strings.Join(colNames, " and "))},
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{
@@ -627,8 +515,7 @@ func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
&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")}},
},
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{},
@@ -677,33 +564,10 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
}
}
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByCol()` function, for a unique
// index on a single column.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByName()" function.
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByUniqColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
return GenerateGetItemBy(tbl, []schema.Column{col})
}
// GenerateGetItemsBy produces an AST for a `GetXyzsByCol1AndCol2...()` function that returns
// all items matching an exact-match lookup over the given columns. Used for non-unique
// indexes (single- or multi-column).
// E.g., a table with `table.TableName = "foods"` and a non-unique index on "category" will
// produce a "GetFoodsByCategory(category string) []Food" function.
func GenerateGetItemsBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
paramNames := paramNamesFor(cols)
colNames := []string{}
funcNameSuffix := []string{}
funcParams := &ast.FieldList{List: []*ast.Field{}}
sqlParams := []ast.Expr{}
for i, col := range cols {
funcParam := ast.NewIdent(paramNames[i])
funcParams.List = append(funcParams.List, &ast.Field{Names: []*ast.Ident{funcParam}, Type: GoTypeForColumn(col)})
colNames = append(colNames, fmt.Sprintf("%s = ?", col.Name))
funcNameSuffix = append(funcNameSuffix, col.GoFieldName())
sqlParams = append(sqlParams, funcParam)
}
// Use the xyzSQLFields constant in the select query
selectExpr := &ast.BinaryExpr{
X: &ast.BinaryExpr{
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),
},
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{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Select")},
Args: append([]ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr}, sqlParams...),
})
param := ast.NewIdent(col.GoVarName())
return &ast.FuncDecl{
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{
Params: funcParams,
Params: &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{param}, Type: GoTypeForColumn(col)},
}},
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{
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{},
},
},
}
}
// GenerateGetItemsByColFunc produces an AST for the `GetXyzsByCol()` function, for a non-unique
// index on a single column.
// E.g., a table with `table.TableName = "foods"` and a non-unique index on "category" will
// produce a "GetFoodsByCategory(category string) []Food" function.
func GenerateGetItemsByColFunc(tbl schema.Table, col schema.Column) *ast.FuncDecl {
return GenerateGetItemsBy(tbl, []schema.Column{col})
}
// GenerateGetAllItemsFunc produces an AST for the `GetAllXyzs()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
@@ -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)}},
}}
selectCall := doCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: dbDB,
Sel: ast.NewIdent("Select"),
},
selectCall := &ast.CallExpr{
Fun: ast.NewIdent("PanicIf"),
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),
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: dbDB,
Sel: ast.NewIdent("Select"),
},
Args: []ast.Expr{
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
&ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`select `"},
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{
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
}
// GenerateSoftDeleteItemFunc produces an AST for the `SoftDeleteXyz()` function, which is only
// generated for tables that have both an "is_deleted" and a "deleted_at" column.
// E.g., a table with `table.TypeName = "foods"` will produce a "SoftDeleteFood()" function.
func GenerateSoftDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
whereClauses := []string{}
for _, c := range tbl.PrimaryKeyColumns() {
whereClauses = append(whereClauses, fmt.Sprintf("%s = :%s", c.Name, c.Name))
}
setPairs := []string{"is_deleted = :is_deleted", "deleted_at = :deleted_at"}
updateStmt := fmt.Sprintf("\n\t update %s\n\t set %s\n\t where %s\n\t",
tbl.TableName,
strings.Join(setPairs, ",\n\t "),
strings.Join(whereClauses, " and "),
)
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
// item.IsDeleted = true
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(tbl.GetColumnByName("is_deleted").GoFieldName())}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{ast.NewIdent("true")},
},
// item.DeletedAt = TimestampNow()
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent(tbl.GetColumnByName("deleted_at").GoFieldName())}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
},
// result := must.Get(db.DB.NamedExec(...))
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + updateStmt + "`"},
ast.NewIdent(tbl.VarName),
},
})},
},
MustBeRowsAffected(tbl),
},
}
return &ast.FuncDecl{
Doc: &ast.CommentGroup{List: []*ast.Comment{
{Text: fmt.Sprintf("// SoftDelete%s sets deleted-at and marks the item as deleted.", tbl.GoTypeName)},
}},
Recv: dbRecv,
Name: ast.NewIdent("SoftDelete" + tbl.GoTypeName),
Type: &ast.FuncType{Params: &ast.FieldList{List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)},
Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)},
}}}, Results: nil},
Body: funcBody,
}
}
// GenerateSQLFieldsConst produces an AST for the `const xyzSQLFields = ...` string.
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
columns := make([]string, 0, len(tbl.Columns))

View File

@@ -4,8 +4,6 @@ import (
"fmt"
"go/ast"
"go/token"
"slices"
"strings"
"github.com/jinzhu/inflection"
@@ -13,77 +11,6 @@ import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
// SampleValue returns a deterministic value of the appropriate type for the given column.
func SampleValue(c pkgschema.Column, offset int) ast.Expr {
switch c.Type {
case "integer", "int":
if strings.HasPrefix(c.Name, "is_") || strings.HasPrefix(c.Name, "has_") {
// Boolean case
if offset%2 == 0 {
return ast.NewIdent("false")
}
return ast.NewIdent("true")
} else if strings.HasSuffix(c.Name, "_at") {
// Timestamp case
return &ast.CallExpr{
Fun: ast.NewIdent("TimestampFromUnix"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10000+offset)},
},
}
} else {
// Regular integer case
return &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("%d", 10+offset)}
}
case "text":
if offset == 0 {
return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", "asdf")}
}
return &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", fmt.Sprintf("asdf%d", offset))}
case "real":
return &ast.BasicLit{Kind: token.FLOAT, Value: fmt.Sprintf("%.2f", 1.23+float64(offset))}
case "blob":
return &ast.CompositeLit{
Type: &ast.ArrayType{
Elt: ast.NewIdent("byte"),
},
Elts: func() []ast.Expr {
ret := []ast.Expr{
&ast.BasicLit{Kind: token.INT, Value: "72"}, // 'H'
&ast.BasicLit{Kind: token.INT, Value: "105"}, // 'i'
&ast.BasicLit{Kind: token.INT, Value: "33"}, // '!'
}
for range offset {
ret = append(ret,
&ast.BasicLit{Kind: token.INT, Value: "33"}, // '!'
)
}
return ret
}(),
}
default:
panic("Unrecognized sqlite column type: " + c.Type)
}
}
// UpdateTestFields returns the columns for which the test generator should assign its own
// sample values in MakeXyz() and the create/update test. It starts with Table.GetUpdateableColumns()
// and removes fields the generated test should not assign directly: foreign keys and auto-managed
// updated_at.
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
_, hasUpdatedAt := tbl.HasAutoTimestamps()
for _, c := range tbl.GetUpdateableColumns() {
if c.IsForeignKey {
continue
}
if c.Name == "updated_at" && hasUpdatedAt {
continue
}
ret = append(ret, c)
}
return
}
// GenerateModelTestAST produces an AST for a starter test file for a given model.
func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodName string) *ast.File {
packageName := "db"
@@ -91,57 +18,11 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName)
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
updateTestFields := UpdateTestFields(tbl)
uniqueColumns := map[string]bool{}
for _, index := range schema.Indexes {
if index.TableName != tbl.TableName || !index.IsUnique {
continue
}
for _, columnName := range index.Columns {
uniqueColumns[columnName] = true
}
}
hasRandomUniqueField := false
for _, c := range updateTestFields {
if uniqueColumns[c.Name] {
hasRandomUniqueField = true
break
}
}
requiredForeignKeys := []pkgschema.Column{}
for _, c := range tbl.Columns {
// Only ordinary object foreign keys use a distinct TargetObjectID Go type;
// code-table foreign keys retain their underlying SQLite type in the generated model.
if c.IsNotNull && c.IsNonCodeTableForeignKey() {
requiredForeignKeys = append(requiredForeignKeys, c)
}
}
requiredForeignKeyParamNames := paramNamesFor(requiredForeignKeys)
// Calls made by this generated test do not create related objects themselves. Pass a
// zero ID so the generated test remains syntactically valid; callers of MakeXyz provide
// IDs of the related objects they create.
makeItemArgs := func() (ret []ast.Expr) {
for range requiredForeignKeys {
ret = append(ret, &ast.BasicLit{Kind: token.INT, Value: "0"})
}
return
}()
// func MakeItem() Item { return Item{} }
makeItemFunc := &ast.FuncDecl{
Name: makeHelperName,
Type: &ast.FuncType{
Params: &ast.FieldList{List: func() (ret []*ast.Field) {
for i, c := range requiredForeignKeys {
ret = append(ret, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(requiredForeignKeyParamNames[i])},
Type: GoTypeForColumn(c),
})
}
return
}()},
Params: &ast.FieldList{},
Results: &ast.FieldList{
List: []*ast.Field{
{Type: ast.NewIdent(tbl.GoTypeName)},
@@ -149,142 +30,44 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
},
},
Body: &ast.BlockStmt{
List: append(
func() []ast.Stmt {
if !hasRandomUniqueField {
return nil
}
return []ast.Stmt{&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("id")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("rand"), Sel: ast.NewIdent("Int")}}},
}}
}(),
[]ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName),
Elts: func() (ret []ast.Expr) {
// `UpdateTestFields()` skips foreign keys, so this will not cause duplicates
for i, c := range requiredForeignKeys {
ret = append(ret, &ast.KeyValueExpr{
Key: ast.NewIdent(c.GoFieldName()),
Value: ast.NewIdent(requiredForeignKeyParamNames[i]),
})
}
for _, c := range updateTestFields {
value := SampleValue(c, 0)
if uniqueColumns[c.Name] {
switch c.Type {
case "integer", "int":
value = ast.NewIdent("id")
case "text":
value = &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"%s-%d"`},
SampleValue(c, 0),
ast.NewIdent("id"),
},
}
case "blob":
value = &ast.CallExpr{
Fun: &ast.ArrayType{Elt: ast.NewIdent("byte")},
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"%s-%d"`},
&ast.CallExpr{Fun: ast.NewIdent("string"), Args: []ast.Expr{SampleValue(c, 0)}},
ast.NewIdent("id"),
},
}},
}
}
}
ret = append(ret, &ast.KeyValueExpr{
Key: ast.NewIdent(c.GoFieldName()),
Value: value,
})
}
return
}(),
List: []ast.Stmt{
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName),
Elts: []ast.Expr{
&ast.KeyValueExpr{
Key: ast.NewIdent("Data"),
Value: &ast.CompositeLit{
Type: &ast.ArrayType{
Elt: ast.NewIdent("byte"),
},
Elts: []ast.Expr{},
},
},
&ast.KeyValueExpr{
Key: ast.NewIdent("Description"),
Value: &ast.BasicLit{
Kind: token.STRING,
Value: `""`,
},
},
},
},
},
}...,
),
},
},
},
}
testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
fieldName := ast.NewIdent("Description") // TODO
description1 := `"an item"`
description2 := `"a big item"`
testDB := ast.NewIdent("TestDB")
// getItemByPKCall builds a call to this table's primary-key getter (e.g. `TestDB.GetItemByID(item.ID)`,
// or `TestDB.GetItemByColAAndColB(item.ColA, item.ColB)` for "without rowid" tables with a
// compound primary key), matching whatever GenerateGetItemByIDFunc/GenerateGetItemBy generated.
getItemByPKCall := func(obj *ast.Ident) *ast.CallExpr {
if !tbl.IsWithoutRowid {
// Normal rowid table: use GetXyzByID
return &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "ByID")},
Args: []ast.Expr{&ast.SelectorExpr{X: obj, Sel: ast.NewIdent("ID")}},
}
} else {
// "Without rowid" table: use the primary key "GetItemByBlahBlah" query func
funcNameSuffix := []string{}
args := []ast.Expr{}
for _, c := range tbl.PrimaryKeyColumns() {
funcNameSuffix = append(funcNameSuffix, c.GoFieldName())
args = append(args, &ast.SelectorExpr{X: obj, Sel: ast.NewIdent(c.GoFieldName())})
}
return &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Get" + tbl.GoTypeName + "By" + strings.Join(funcNameSuffix, "And"))},
Args: args,
}
}
}
makeDeepEqual := func(obj1 *ast.Ident, obj2 *ast.Ident) *ast.IfStmt {
return &ast.IfStmt{
Init: &ast.AssignStmt{
Lhs: []ast.Expr{
&ast.Ident{Name: "diff"},
},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: &ast.Ident{Name: "deep"},
Sel: &ast.Ident{Name: "Equal"},
},
Args: []ast.Expr{obj1, obj2},
},
},
},
Cond: &ast.BinaryExpr{
X: &ast.Ident{Name: "diff"},
Op: token.NEQ,
Y: &ast.Ident{Name: "nil"},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ExprStmt{
X: &ast.CallExpr{
Fun: &ast.SelectorExpr{
X: &ast.Ident{Name: "t"},
Sel: &ast.Ident{Name: "Error"},
},
Args: []ast.Expr{
&ast.Ident{Name: "diff"},
},
},
},
},
},
}
}
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
testFuncType := &ast.FuncType{
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{
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
Type: testFuncType,
@@ -412,47 +97,36 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
&ast.AssignStmt{
Lhs: []ast.Expr{testObj},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: makeItemArgs}},
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}},
},
// item.Description = <sample value, offset 1>
// ...one assignment per updatable field
}
for _, c := range updateTestFields {
stmts = append(stmts, &ast.AssignStmt{
// item.Description = "an item"
&ast.AssignStmt{
Lhs: []ast.Expr{
&ast.SelectorExpr{
X: testObj,
Sel: ast.NewIdent(c.GoFieldName()),
Sel: ast.NewIdent("Description"),
},
},
Tok: token.ASSIGN,
Rhs: []ast.Expr{SampleValue(c, 1)},
})
}
stmts = append(stmts,
// TestDB.SaveItem(&item), possibly with error check
&ast.ExprStmt{X: func() *ast.CallExpr {
mainExpr := &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}
if shouldIncludeTestFkCheck {
// Also a check for whether the Save function returns an error
return &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NoError")},
Args: []ast.Expr{ast.NewIdent("t"), mainExpr},
}
}
return mainExpr
}()},
)
Rhs: []ast.Expr{
&ast.BasicLit{
Kind: token.STRING,
Value: fmt.Sprintf("%q", description1),
},
},
},
// require.NotZero(t, item.ID)
if !tbl.IsWithoutRowid { // non-rowid tables don't get an ID
stmts = append(stmts, &ast.ExprStmt{X: &ast.CallExpr{
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// require.NotZero(t, item.ID)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("require"), Sel: ast.NewIdent("NotZero")},
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}})
}},
}
// After create: assert timestamps are set
@@ -467,51 +141,64 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
BlankLine(),
Comment("Load"),
// item2 := must.Get(TestDB.GetItemByID(item.ID))
// item2 := Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
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(...) {...}
makeDeepEqual(testObj, testObj2),
// 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 len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
stmts = append(stmts,
BlankLine(),
Comment("Update"),
)
stmts = append(stmts,
BlankLine(),
Comment("Update"),
// item.Description = <sample value, offset 2>
// ...one assignment per updatable field
for _, c := range updateTestFields {
stmts = append(stmts, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{SampleValue(c, 2)},
})
}
// item.Description = "a big item"
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
},
stmts = append(stmts,
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item2 = must.Get(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
// item2 = Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: 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{
BlankLine(),
@@ -523,21 +210,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
// Skip indexes on other tables
continue
}
if slices.Contains(index.Columns, "") {
// Skip expression indexes; there's no way to resolve an expression to a real column
continue
}
cols := make([]pkgschema.Column, len(index.Columns))
nameParts := make([]string, len(index.Columns))
callArgs := make([]ast.Expr, len(index.Columns))
for i, colName := range index.Columns {
cols[i] = tbl.GetColumnByName(colName)
nameParts[i] = cols[i].GoFieldName()
callArgs[i] = &ast.SelectorExpr{X: testObj2, Sel: ast.NewIdent(cols[i].GoFieldName())}
}
funcNameSuffix := strings.Join(nameParts, "And")
if index.IsUnique {
if index.IsUnique && len(index.Columns) == 1 {
col := tbl.GetColumnByName(index.Columns[0])
indexGets = append(indexGets, []ast.Stmt{
// assert.Equal(t, item2, TestDB.GetItemByXYZ(...))
&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,
mustCall(&ast.CallExpr{
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 {
indexGets = append(indexGets, []ast.Stmt{
// assert.Contains(t, TestDB.GetItemsByXYZ(...), item2)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Contains")},
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent(
"Get" + inflection.Plural(pkgschema.TypenameFromTablename(tbl.TableName)) +
"By" + funcNameSuffix,
)},
Args: callArgs,
},
testObj2,
},
}},
}...)
// decls = append(decls, modelgenerate.GenerateGetItemByUniqColFunc(table, table.GetColumnByName(index.Columns[0])))
hasIndexedGets = true
}
hasIndexedGets = true
}
if hasIndexedGets {
@@ -594,7 +251,10 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{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)
@@ -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{
makeItemFunc,
testCreateUpdateDelete,
@@ -645,33 +392,23 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Decls: append([]ast.Decl{
&ast.GenDecl{
Tok: token.IMPORT,
Specs: func() []ast.Spec {
specs := []ast.Spec{}
if hasRandomUniqueField {
specs = append(specs,
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"math/rand"`}},
)
}
specs = append(specs, []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`},
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/go-test/deep"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
}...)
return specs
}(),
Specs: []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
Name: ast.NewIdent("."),
},
&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/stretchr/testify/assert"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
},
},
}, testList...),
}

View File

@@ -7,7 +7,7 @@ import (
"os/exec"
"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"
@@ -22,31 +22,31 @@ type PkgOpts struct {
func InitPkg(opts PkgOpts) {
// Run `go mod init`
fmt.Printf("Running... `go mod init %s`\n", opts.ModuleName)
must.Do(exec.Command("go", "mod", "init", opts.ModuleName).Run())
PanicIf(exec.Command("go", "mod", "init", opts.ModuleName).Run())
// Run `git init`, if required
if exec.Command("git", "status").Run() != nil {
// Not in a git repo yet; init one
fmt.Println("Running... `git init`")
must.Do(exec.Command("git", "init").Run())
PanicIf(exec.Command("git", "init").Run())
}
// Create package structure
must.Do(os.MkdirAll("pkg/db", 0o755))
must.Do(os.MkdirAll("cmd", 0o755))
must.Do(os.MkdirAll("doc", 0o755))
must.Do(os.MkdirAll("sample_data", 0o755))
PanicIf(os.MkdirAll("pkg/db", 0o755))
PanicIf(os.MkdirAll("cmd", 0o755))
PanicIf(os.MkdirAll("doc", 0o755))
PanicIf(os.MkdirAll("sample_data", 0o755))
must.Do(os.WriteFile("pkg/db/schema.sql", must.Get(tpl.ReadFile("tpl/schema.sql")), 0o664))
must.Do(os.WriteFile("pkg/db/db.go", must.Get(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
PanicIf(os.WriteFile("pkg/db/schema.sql", Must(tpl.ReadFile("tpl/schema.sql")), 0o664))
PanicIf(os.WriteFile("pkg/db/db.go", Must(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
dbTest := must.Get(os.Create("pkg/db/db_test.go"))
defer must.Close(dbTest)
t := must.Get(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
must.Do(t.Execute(dbTest, opts))
dbTest := Must(os.Create("pkg/db/db_test.go"))
defer MustClose(dbTest)
t := Must(template.ParseFS(tpl, "tpl/db_test.go.tpl"))
PanicIf(t.Execute(dbTest, opts))
must.Do(os.WriteFile("sample_data/mount.sh", must.Get(tpl.ReadFile("tpl/mount.sh")), 0o775))
must.Do(os.WriteFile("sample_data/reset.sh", must.Get(tpl.ReadFile("tpl/reset.sh")), 0o775))
PanicIf(os.WriteFile("sample_data/mount.sh", Must(tpl.ReadFile("tpl/mount.sh")), 0o775))
PanicIf(os.WriteFile("sample_data/reset.sh", Must(tpl.ReadFile("tpl/reset.sh")), 0o775))
// TODO:
// - create `pkg/db/errors.go`

View File

@@ -13,7 +13,7 @@ import (
var sql_schema string
// Database starts at version 0. First migration brings us to version 1
var MIGRATIONS = []db.Migration{}
var MIGRATIONS = []string{}
type DB struct {
DB *sqlx.DB

View File

@@ -3,7 +3,7 @@ package db_test
import (
"fmt"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
. "{{ .ModuleName }}/pkg/db"
)
@@ -14,6 +14,6 @@ func init() {
TestDB = MakeDB("tmp")
}
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
}

View File

@@ -8,8 +8,6 @@ import (
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
type DBConfig struct {
@@ -17,18 +15,32 @@ type DBConfig struct {
// is_initialized bool
// 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
migrations *[]Migration
versionNumber uint
migrations *[]string
version_number uint
}
func Init(schema *string, migrationsList *[]Migration) DBConfig {
// Colors for terminal output
const (
ColorReset = "\033[0m"
ColorBlack = "\033[30m"
ColorRed = "\033[31m"
ColorGreen = "\033[32m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorPurple = "\033[35m"
ColorCyan = "\033[36m"
ColorGray = "\033[37m"
ColorWhite = "\033[97m"
)
func Init(schema *string, migrationsList *[]string) DBConfig {
return DBConfig{
sqlSchema: schema,
migrations: migrationsList,
versionNumber: uint(len(*migrationsList)),
sql_schema: schema,
migrations: 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 {
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)
}
@@ -79,37 +91,50 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
return fmt.Errorf("couldn't check database version: %w", err)
}
if version > c.versionNumber {
return VersionMismatchError{c.versionNumber, version}
if version > c.version_number {
return VersionMismatchError{c.version_number, version}
}
if c.versionNumber > version {
fmt.Print(textutils.ColorYellow)
if c.version_number > version {
fmt.Print(ColorYellow)
fmt.Printf("================================================\n")
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
c.versionNumber)
fmt.Print(textutils.ColorReset)
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)
c.version_number)
fmt.Print(ColorReset)
c.UpgradeFromXToY(db, version, c.version_number)
}
return nil
}
// UpgradeFromXToY runs all the migrations from version X to version Y, and update the `database_version` table's `version_number`
func (c DBConfig) UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
for i := x; i < y; i++ {
fmt.Print(ColorCyan)
fmt.Println((*c.migrations)[i])
fmt.Print(ColorReset)
// Execute the migration in a transaction
tx, err := db.Beginx()
if err != nil {
panic(err)
}
tx.MustExec((*c.migrations)[i])
tx.MustExec("update db_version set version = ?", i+1)
if err := tx.Commit(); err != nil {
panic(err)
}
fmt.Print(ColorYellow)
fmt.Printf("Now at database schema version %d.\n", i+1)
fmt.Print(ColorReset)
}
fmt.Print(ColorGreen)
fmt.Printf("================================================\n")
fmt.Printf("Database version has been upgraded to version %d.\n", y)
fmt.Print(ColorReset)
}
type VersionMismatchError struct {
EngineVersion uint
DatabaseVersion uint

View File

@@ -15,17 +15,17 @@ import (
func TestCreateAndConnectToDB(t *testing.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)
schemaSQL := string(_schemaSQL)
migrations := []db.Migration{}
schema_sql := string(_schema_sql)
migrations := []string{}
config := db.Init(&schemaSQL, &migrations)
config := db.Init(&schema_sql, &migrations)
dataDir := "../../sample_data/data"
_ = os.MkdirAll(dataDir, os.FileMode(0o644))
data_dir := "../../sample_data/data"
_ = os.MkdirAll(data_dir, os.FileMode(0o644))
// 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
_, err = config.Create(dbPath)
@@ -38,18 +38,18 @@ func TestVersionUpgrade(t *testing.T) {
require := require.New(t)
// Create a little schema
initialSchema := `
initial_schema := `
create table items(rowid integer primary key);
create table db_version (version integer primary key) strict, without rowid;
insert into db_version values(0);
`
migrations := []db.Migration{}
config := db.Init(&initialSchema, &migrations)
migrations := []string{}
config := db.Init(&initial_schema, &migrations)
connection, err := config.Create(":memory:")
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
require.NoError(c.Get(&ret, "select version from db_version"))
return
@@ -58,22 +58,22 @@ func TestVersionUpgrade(t *testing.T) {
var items []int
require.NoError(connection.Select(&items, "select * from items"))
require.Len(items, 0)
require.Equal(0, getVersion(connection))
require.Equal(0, get_version(connection))
// Create a migration to add a new Item
migrations = append(migrations, db.Migration{ID: 1, SQL: "insert into items (rowid) values (1)"})
config = db.Init(&initialSchema, &migrations) // Reinitialize with the new migration
require.NoError(config.CheckAndUpdateVersion(connection))
migrations = append(migrations, "insert into items (rowid) values (1)")
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
var items2 []int
require.NoError(connection.Select(&items2, "select * from items"))
require.Len(items2, 1)
require.Equal(1, getVersion(connection))
require.Equal(1, get_version(connection))
// Create a migration to add a new Item
migrations = append(migrations, db.Migration{ID: 2, SQL: `alter table items add column name string default 'asdf'`})
config = db.Init(&initialSchema, &migrations) // Reinitialize with the new migration
require.NoError(config.CheckAndUpdateVersion(connection))
migrations = append(migrations, `alter table items add column name string default 'asdf'`)
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
var items3 []struct {
ID uint64 `db:"rowid"`
@@ -82,5 +82,5 @@ func TestVersionUpgrade(t *testing.T) {
require.NoError(connection.Select(&items3, "select * from items"))
require.Len(items2, 1)
assert.Equal(t, "asdf", items3[0].Name)
require.Equal(2, getVersion(connection))
require.Equal(2, get_version(connection))
}

View File

@@ -12,8 +12,6 @@ var (
ErrItemIsDeleted = errors.New("item is deleted")
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
ErrDatabaseAlreadyExists = errors.New("target already exists")
ErrMigrationOrder = errors.New("migration in wrong order")
)
type ForeignKey interface {

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
})
}

View 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())
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -8,9 +8,15 @@ import (
// Column represents a single column in a table.
type Column struct {
TableName string `db:"table_name"`
Name string `db:"column_name"`
Type string `db:"column_type"`
// TableName is the name of the SQLite table this column belongs to.
TableName string `db:"table_name"`
// Name is the SQLite column name.
Name string `db:"column_name"`
// Type is the SQLite type this column contains.
Type string `db:"column_type"`
IsNotNull bool `db:"notnull"`
HasDefaultValue bool `db:"has_default_value"`
DefaultValue string `db:"dflt_value"`

View File

@@ -1,19 +1,10 @@
package schema
type IndexOrigin string
const (
IndexOriginCreateIndex = IndexOrigin("c")
IndexOriginUniqueConstraint = IndexOrigin("u")
IndexOriginPrimaryKey = IndexOrigin("pk")
)
type Index struct {
Name string `db:"index_name"`
TableName string `db:"table_name"`
Columns []string
IsUnique bool `db:"is_unique"`
Origin IndexOrigin `db:"origin"`
IsUnique bool `db:"is_unique"`
// TODO: `where ...` for partial indexes
// TODO: identify columns that are expressions
}

View File

@@ -114,8 +114,8 @@ var Checks = []Check{
}
// Check if target column is a primary key
for _, targetCol := range s.Tables[column.ForeignKeyTargetTable].Columns {
if targetCol.Name == column.ForeignKeyTargetColumn && targetCol.IsPrimaryKey {
for _, target_col := range s.Tables[column.ForeignKeyTargetTable].Columns {
if target_col.Name == column.ForeignKeyTargetColumn && target_col.IsPrimaryKey {
continue fk_loop
}
}
@@ -125,8 +125,8 @@ var Checks = []Check{
if idx.TableName != column.ForeignKeyTargetTable {
continue
}
for _, idxColName := range idx.Columns {
if column.ForeignKeyTargetColumn == idxColName {
for _, idx_col_name := range idx.Columns {
if column.ForeignKeyTargetColumn == idx_col_name {
continue fk_loop
}
break // Only look at 1st column

View File

@@ -12,7 +12,7 @@ import (
)
func TestFailureCases(t *testing.T) {
testCases := []struct {
test_cases := []struct {
sqlFile string
expectedFailures []string
}{
@@ -34,24 +34,24 @@ func TestFailureCases(t *testing.T) {
}},
}
for _, testCase := range testCases {
t.Run(testCase.sqlFile, func(t *testing.T) {
schema, err := schema.SchemaFromSQLFile(testCase.sqlFile)
for _, test_case := range test_cases {
t.Run(test_case.sqlFile, func(t *testing.T) {
schema, err := schema.SchemaFromSQLFile(test_case.sqlFile)
require.NoError(t, err)
for _, check := range lint.Checks {
results := check.Execute(schema)
isFailure := len(results) > 0
isFailureExpected := slices.Contains(testCase.expectedFailures, check.Name)
is_failure := len(results) > 0
is_failure_expected := slices.Contains(test_case.expectedFailures, check.Name)
if isFailure != isFailureExpected {
if isFailureExpected {
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, testCase.sqlFile)
if is_failure != is_failure_expected {
if is_failure_expected {
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, test_case.sqlFile)
} else {
t.Errorf("Expected check '%s' to pass, but it failed: %s (%q.%q)",
check.Name,
testCase.sqlFile,
test_case.sqlFile,
results[0].TableName, results[0].ColumnName,
)
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
@@ -50,14 +50,14 @@ func TestVerifyCorrectMigration(t *testing.T) {
db1Schema := schema.SchemaFromDB(db1)
t.Run("migrate in 1 step", func(t *testing.T) {
migration := db.Migration{ID: 1, SQL: `
migration := `
create table t2 (
rowid integer primary key
);
alter table t1 add column data3 integer;
`}
db2Config := db.Init(&baseSchema, &[]db.Migration{migration})
db2 := must.Get(db2Config.Create(":memory:"))
`
db2Config := db.Init(&baseSchema, &[]string{migration})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
@@ -67,17 +67,17 @@ func TestVerifyCorrectMigration(t *testing.T) {
})
t.Run("migrate in 2 steps", func(t *testing.T) {
migration1 := db.Migration{ID: 1, SQL: `
migration1 := `
create table t2 (
rowid integer primary key
);
`}
migration2 := db.Migration{ID: 2, SQL: `
`
migration2 := `
alter table t1 add column data3 integer;
`}
`
db2Config := db.Init(&baseSchema, &[]db.Migration{migration1, migration2})
db2 := must.Get(db2Config.Create(":memory:"))
db2Config := db.Init(&baseSchema, &[]string{migration1, migration2})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
@@ -92,8 +92,8 @@ func TestIncorrectMigrations(t *testing.T) {
db1Schema := schema.SchemaFromDB(db1)
t.Run("missing migration", func(t *testing.T) {
db2Config := db.Init(&baseSchema, &[]db.Migration{})
db2 := must.Get(db2Config.Create(":memory:"))
db2Config := db.Init(&baseSchema, &[]string{})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)
@@ -109,14 +109,12 @@ func TestIncorrectMigrations(t *testing.T) {
})
t.Run("incomplete migration", func(t *testing.T) {
db2Config := db.Init(&baseSchema, &[]db.Migration{
{ID: 1, SQL: `
db2Config := db.Init(&baseSchema, &[]string{`
create table t2 (
rowid integer primary key
);
`},
})
db2 := must.Get(db2Config.Create(":memory:"))
`})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(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) {
db2Config := db.Init(&baseSchema, &[]db.Migration{
{ID: 1, SQL: `
create table t2 (
rowid integer primary key
);
alter table t1 add column data3 text;
`},
})
db2 := must.Get(db2Config.Create(":memory:"))
db2Config := db.Init(&baseSchema, &[]string{`
create table t2 (
rowid integer primary key
);
alter table t1 add column data3 text;
`})
db2 := flowutils.Must(db2Config.Create(":memory:"))
require.NoError(t, db2Config.CheckAndUpdateVersion(db2))
db2Schema := schema.SchemaFromDB(db2)

View File

@@ -10,17 +10,17 @@ import (
"github.com/jmoiron/sqlx"
_ "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"
)
//go:embed views.sql
var createViews string
var create_views 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.MustExec(sqlSchema)
db.MustExec(sql_schema)
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.
func SchemaFromDB(db *sqlx.DB) Schema {
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
db.MustExec(createViews)
db.MustExec(create_views)
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 {
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
tbl.TypeIDName = tbl.GoTypeName + "ID"
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
}
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 {
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
}
return ret

View File

@@ -12,15 +12,15 @@ import (
func TestParseSchema(t *testing.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)
db := schema.InitDB(string(schemaSQL))
db := schema.InitDB(string(schema_sql))
schema := schema.SchemaFromDB(db)
expectedTbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
for _, tblName := range expectedTbls {
_, isOk := schema.Tables[tblName]
assert.True(isOk)
expected_tbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
for _, tbl_name := range expected_tbls {
_, is_ok := schema.Tables[tbl_name]
assert.True(is_ok)
}
foods := schema.Tables["foods"]
@@ -57,23 +57,23 @@ func TestParseSchema(t *testing.T) {
// -------
// Unique index
unitsNameIdx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
units_name_idx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
require.True(t, isOk)
assert.True(unitsNameIdx.IsUnique)
assert.Equal("units", unitsNameIdx.TableName)
assert.Equal([]string{"name"}, unitsNameIdx.Columns)
assert.True(units_name_idx.IsUnique)
assert.Equal("units", units_name_idx.TableName)
assert.Equal([]string{"name"}, units_name_idx.Columns)
// Non-unique, declared index
foodsProteinIdx, isOk := schema.Indexes["foods_protein"]
foods_protein_idx, isOk := schema.Indexes["foods_protein"]
require.True(t, isOk)
assert.False(foodsProteinIdx.IsUnique)
assert.Equal("foods", foodsProteinIdx.TableName)
assert.Equal([]string{"protein"}, foodsProteinIdx.Columns)
assert.False(foods_protein_idx.IsUnique)
assert.Equal("foods", foods_protein_idx.TableName)
assert.Equal([]string{"protein"}, foods_protein_idx.Columns)
// 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)
assert.True(ingrSeqIdx.IsUnique)
assert.Equal("ingredients", ingrSeqIdx.TableName)
assert.Equal([]string{"in_recipe_id", "list_order"}, ingrSeqIdx.Columns)
assert.True(ingr_seq_idx.IsUnique)
assert.Equal("ingredients", ingr_seq_idx.TableName)
assert.Equal([]string{"in_recipe_id", "list_order"}, ingr_seq_idx.Columns)
}

View File

@@ -1,7 +1,12 @@
package schema
import (
"fmt"
"slices"
"sort"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"github.com/jmoiron/sqlx"
)
// Table is a single SQLite table.
@@ -62,35 +67,10 @@ func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
return
}
// HasSoftDelete reports whether this table supports soft-deletion, i.e., whether it has both
// an "is_deleted" and a "deleted_at" column.
func (t Table) HasSoftDelete() bool {
var hasIsDeleted, hasDeletedAt bool
for _, c := range t.Columns {
if c.Name == "is_deleted" && c.Type == "integer" {
hasIsDeleted = true
}
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)
func (t Table) GetCodeTableValues(db *sqlx.DB) (ret []string) {
if !slices.ContainsFunc(t.Columns, func(c Column) bool { return c.Name == "name" }) {
panic("not a code table")
}
flowutils.PanicIf(db.Select(&ret, fmt.Sprintf("select name from %s", t.TableName)))
return
}

View File

@@ -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)
}
}
}

View File

@@ -27,8 +27,7 @@ create temporary view columns as
create temporary view indexes as
select idx.name as index_name,
tables.name as table_name,
idx."unique" as is_unique,
idx.origin as origin
idx."unique" as is_unique
from tables
join pragma_index_list(tables.name) idx;

View File

@@ -10,6 +10,16 @@ func SnakeToCamel(s string) string {
return strings.Join(parts, "")
}
func KebabToPascal(s string) string {
parts := strings.Split(s, "-")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
func CamelToPascal(s string) string {
return strings.ToLower(s)[0:1] + s[1:]
}

View File

@@ -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"
)

View File

@@ -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)
}

View 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');