11 Commits

Author SHA1 Message Date
12fc338947 doc: add TODO items
Some checks failed
CI / build-docker (push) Successful in 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 7s
2026-10-24 19:23:41 -07:00
c70732d6af lint: enable custom linters from go-lint project 2026-10-24 19:02:01 -07:00
ef71828ce2 lint: convert snake to camel case 2026-10-24 18:58:31 -07:00
f8d79761f4 housekeeping: clean up lint config 2026-10-24 18:50:26 -07:00
de11a0214b generic: add MutexMap
All checks were successful
CI / build-docker (push) Successful in 2s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 10s
2026-10-23 17:21:47 -07:00
936df98cc4 db: add VSC
All checks were successful
CI / build-docker (push) Successful in 2s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 2m17s
2026-10-23 17:16:35 -07:00
56b264fcb3 devcontainer: bump golangci-lint version from 2.0.2 => 2.14.0
All checks were successful
CI / build-docker (push) Successful in 1m11s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 40s
2026-10-14 14:24:31 -07:00
99b57c88b0 ops: bump gas container Alpine from 3.22 => 3.24
Some checks failed
CI / build-docker (push) Successful in 54s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 4m50s
2026-10-14 13:56:13 -07:00
6cf0b66dd5 codegen: CreateUpdateDelete test no longer creates an "Update" block if there's no updateable fields 2026-10-11 18:11:25 -07:00
f98f8416e8 codegen: test fixture factories now use randomness to generate unique column values
All checks were successful
CI / build-docker (push) Successful in 2s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 10s
2026-10-04 22:18:38 -07:00
1592c7d1fc codegen: MakeXyz() fixture factories now take args for required foreign key objects 2026-10-04 20:40:19 -07:00
31 changed files with 623 additions and 205 deletions

10
.custom-gcl.yaml Normal file
View File

@@ -0,0 +1,10 @@
# Builds a golangci-lint binary with the shared custom linters from go-lint.
#
# The version MUST stay in lockstep with the installed golangci-lint version:
# plugins link against golangci-lint internals.
version: v2.13.1
name: golint
destination: ./bin
plugins:
- module: git.offline-twitter.com/offline-labs/go-lint
version: v0.0.0-20261018231414-f5b6076dce76

1
.gitignore vendored
View File

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

View File

@@ -20,7 +20,20 @@ linters:
- whitespace - whitespace
- wrapcheck - wrapcheck
# Shared custom linters from ../go-lint.
- ifinit
- boolnames
settings: settings:
custom:
ifinit:
type: module
description: if-statement init clauses must be on a single line
boolnames:
type: module
description: boolean variables and functions should use is, has, was, and similar prefixes
settings:
allowed_prefixes: ["is", "has", "can", "was", "should"]
depguard: depguard:
rules: rules:
main: main:
@@ -54,15 +67,16 @@ linters:
require-specific: true require-specific: true
allow-unused: false allow-unused: false
staticcheck: staticcheck:
go: "1.24"
checks: checks:
- all - all
- -ST1000 # Re-enable this once we have docstrings - -ST1000 # Re-enable this once we have docstrings
- -ST1003 # I like snake_case
- -ST1013 # HTTP status codes are shorter and more readable than names - -ST1013 # HTTP status codes are shorter and more readable than names
wrapcheck:
report-internal-errors: false
extra-ignore-sigs:
- json.Marshal(
exclusions: exclusions:
generated: lax # Don't lint generated files generated: lax # Don't lint generated files
paths:
formatters: formatters:
enable: enable:
@@ -78,7 +92,6 @@ formatters:
simplify: true simplify: true
exclusions: exclusions:
generated: lax generated: lax
paths:
run: run:
build-tags: build-tags:

View File

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

View File

@@ -17,7 +17,7 @@ import (
var ErrNoSuchTable = errors.New("no such table") var ErrNoSuchTable = errors.New("no such table")
var generate_model = &cobra.Command{ var generateModel = &cobra.Command{
Use: "generate <model_name>", Use: "generate <model_name>",
Short: "Generate a model type", Short: "Generate a model type",
@@ -119,7 +119,7 @@ var generate_model = &cobra.Command{
} }
func init() { func init() {
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file") generateModel.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)") generateModel.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") generateModel.Flags().Bool("test", false, "Generate test file instead of regular file")
} }

View File

@@ -12,7 +12,7 @@ import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/must" "git.offline-twitter.com/offline-labs/gas-stack/pkg/must"
) )
var cmd_init = &cobra.Command{ var cmdInit = &cobra.Command{
Use: "init [path]", Use: "init [path]",
Short: "Initialize a new project", Short: "Initialize a new project",
Long: "Initialize a new Gas Stack project at the given path. If no path is given, defaults to current directory.", Long: "Initialize a new Gas Stack project at the given path. If no path is given, defaults to current directory.",
@@ -31,7 +31,7 @@ var cmd_init = &cobra.Command{
} }
// Get all the config values // Get all the config values
get_val := func(prompt string, val *string) { getVal := func(prompt string, val *string) {
fmt.Printf("%s (%q): ", prompt, *val) fmt.Printf("%s (%q): ", prompt, *val)
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() scanner.Scan()
@@ -40,31 +40,31 @@ var cmd_init = &cobra.Command{
*val = input *val = input
} }
} }
pkg_opts := codegen.PkgOpts{ pkgOpts := codegen.PkgOpts{
ModuleName: must.Get(cmd.Flags().GetString("module")), ModuleName: must.Get(cmd.Flags().GetString("module")),
DBFilename: must.Get(cmd.Flags().GetString("db")), DBFilename: must.Get(cmd.Flags().GetString("db")),
BinaryName: must.Get(cmd.Flags().GetString("binary")), BinaryName: must.Get(cmd.Flags().GetString("binary")),
} }
if pkg_opts.ModuleName == "" { if pkgOpts.ModuleName == "" {
pkg_opts.ModuleName = filepath.Base(target) pkgOpts.ModuleName = filepath.Base(target)
get_val("module name", &pkg_opts.ModuleName) getVal("module name", &pkgOpts.ModuleName)
} }
if pkg_opts.DBFilename == "" { if pkgOpts.DBFilename == "" {
pkg_opts.DBFilename = pkg_opts.ModuleName + ".db" pkgOpts.DBFilename = pkgOpts.ModuleName + ".db"
get_val("db name", &pkg_opts.DBFilename) getVal("db name", &pkgOpts.DBFilename)
} }
if pkg_opts.BinaryName == "" { if pkgOpts.BinaryName == "" {
pkg_opts.BinaryName = pkg_opts.ModuleName pkgOpts.BinaryName = pkgOpts.ModuleName
get_val("binary name", &pkg_opts.BinaryName) getVal("binary name", &pkgOpts.BinaryName)
} }
// Run project initialization // Run project initialization
codegen.InitPkg(pkg_opts) codegen.InitPkg(pkgOpts)
}, },
} }
func init() { func init() {
cmd_init.Flags().String("module", "", "Module name") cmdInit.Flags().String("module", "", "Module name")
cmd_init.Flags().String("db", "", "Database filename") cmdInit.Flags().String("db", "", "Database filename")
cmd_init.Flags().String("binary", "", "Binary name") cmdInit.Flags().String("binary", "", "Binary name")
} }

View File

@@ -12,7 +12,7 @@ import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint" "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
) )
var sqlite_lint = &cobra.Command{ var sqliteLint = &cobra.Command{
Use: "sqlite_lint <path/to/schema.sql>", Use: "sqlite_lint <path/to/schema.sql>",
Short: "Lint a SQLite schema", Short: "Lint a SQLite schema",
@@ -32,18 +32,18 @@ var sqlite_lint = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
is_failure := false isFailure := false
// Execute each check against the database // Execute each check against the database
for _, check := range lint.Checks { for _, check := range lint.Checks {
// Checks can be disabled via Github config / environment variables // Checks can be disabled via Github config / environment variables
if !is_check_enabled(check) { if !isCheckEnabled(check) {
continue continue
} }
results := check.Execute(schema) results := check.Execute(schema)
// If there are results, print them as lint errors // If there are results, print them as lint errors
if len(results) > 0 { if len(results) > 0 {
is_failure = true isFailure = true
fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name) fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name)
for _, result := range results { for _, result := range results {
fmt.Printf(RED+"- %s: %s.%s\n"+RESET, result.ErrorMsg, result.TableName, result.ColumnName) fmt.Printf(RED+"- %s: %s.%s\n"+RESET, result.ErrorMsg, result.TableName, result.ColumnName)
@@ -51,7 +51,7 @@ var sqlite_lint = &cobra.Command{
fmt.Printf(RED+"Explanation: %s\n\n"+RESET, check.Explanation) fmt.Printf(RED+"Explanation: %s\n\n"+RESET, check.Explanation)
} }
} }
if is_failure { if isFailure {
fmt.Println(RED + "Errors found" + RESET) fmt.Println(RED + "Errors found" + RESET)
os.Exit(1) os.Exit(1)
} }
@@ -61,16 +61,16 @@ var sqlite_lint = &cobra.Command{
// github_actions_input_env_var converts an input name to the corresponding // github_actions_input_env_var converts an input name to the corresponding
// environment variable name used by GitHub Actions. // environment variable name used by GitHub Actions.
func github_actions_input_env_var(name string) string { func githubActionsInputEnvVar(name string) string {
// GitHub normalizes both hyphens and underscores to underscores, then uppercases the name // GitHub normalizes both hyphens and underscores to underscores, then uppercases the name
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name) normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name)
return "INPUT_" + strings.ToUpper(normalized) return "INPUT_" + strings.ToUpper(normalized)
} }
// Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check // Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check
func is_check_enabled(c lint.Check) bool { func isCheckEnabled(c lint.Check) bool {
val, is_set := os.LookupEnv(github_actions_input_env_var(c.Name)) val, isSet := os.LookupEnv(githubActionsInputEnvVar(c.Name))
if !is_set { if !isSet {
// Enable all checks by default // Enable all checks by default
return true return true
} }

View File

@@ -8,7 +8,7 @@ import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/version" "git.offline-twitter.com/offline-labs/gas-stack/pkg/version"
) )
var cmd_version = &cobra.Command{ var cmdVersion = &cobra.Command{
Use: "version", Use: "version",
Short: "Print the version of `gas`", Short: "Print the version of `gas`",
Args: cobra.NoArgs, Args: cobra.NoArgs,

View File

@@ -38,3 +38,15 @@ TODO: test reporter
TODO: foreign key to compound-FK table TODO: foreign key to compound-FK table
- sample: booru_post_tag_votes - 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

@@ -1,3 +1,4 @@
https://astexplorer.net/ https://astexplorer.net/
https://github.com/dave/dst https://github.com/dave/dst
https://disaev.me/p/writing-useful-go-analysis-linter/ https://disaev.me/p/writing-useful-go-analysis-linter/
https://www.sqlservercentral.com/articles/lookup-table-madness

7
doc/todo/admin-pages.md Normal file
View File

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

39
ops/build-lint.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/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,13 +1,13 @@
# A dev container. # A dev container.
FROM alpine:3.22 FROM alpine:3.24
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
# Busybox `less` doesn't appear to support colors (makes git diff lose color) # Busybox `less` doesn't appear to support colors (makes git diff lose color)
RUN apk add less RUN apk add less
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2 RUN curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b /usr/local/bin v2.14.0
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2 RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
COPY etc/group /etc/group COPY etc/group /etc/group

View File

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

16
ops/lint.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/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 +1,3 @@
#!/bin/sh #!/bin/sh
go test -tags fts5 ./... go test -tags fts5 ./... $@

View File

@@ -296,9 +296,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
updatePairs := make([]string, 0, len(tbl.Columns)) updatePairs := make([]string, 0, len(tbl.Columns))
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
hasSoftDelete := tbl.HasSoftDelete()
// Assemble data for building SQL "insert" and "update" strings // Assemble SQL "insert" string
for _, col := range tbl.Columns { for _, col := range tbl.Columns {
if col.Name == "rowid" { if col.Name == "rowid" {
continue continue
@@ -309,23 +308,20 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
val = fmt.Sprintf("nullif(%s, 0)", val) val = fmt.Sprintf("nullif(%s, 0)", val)
} }
insertVals = append(insertVals, val) insertVals = append(insertVals, val)
// created_at should not be updated after creation
if col.Name == "created_at" && hasCreatedAt {
continue
}
// Soft-delete state is managed by SoftDeleteXyz(), not by updates
if hasSoftDelete && (col.Name == "is_deleted" || col.Name == "deleted_at") {
continue
}
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
updatePairs = append(updatePairs, col.Name+"="+val)
}
} }
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t", insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t",
tbl.TableName, tbl.TableName,
strings.Join(insertCols, ", "), strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "), strings.Join(insertVals, ", "),
) )
// ...and "update" string
for _, col := range tbl.GetUpdateableColumns() {
val := ":" + col.Name
if col.IsNullableForeignKey() {
val = fmt.Sprintf("nullif(%s, 0)", val)
}
updatePairs = append(updatePairs, col.Name+"="+val)
}
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t", updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t",
tbl.TableName, tbl.TableName,
strings.Join(updatePairs, ",\n\t\t "), strings.Join(updatePairs, ",\n\t\t "),

View File

@@ -67,26 +67,18 @@ func SampleValue(c pkgschema.Column, offset int) ast.Expr {
} }
// UpdateTestFields returns the columns for which the test generator should assign its own // UpdateTestFields returns the columns for which the test generator should assign its own
// sample values, in the MakeXyz() factory and in the create/update test: the same columns // sample values in MakeXyz() and the create/update test. It starts with Table.GetUpdateableColumns()
// that GenerateSaveItemFunc's "update" branch writes to, minus foreign keys (which can't be // and removes fields the generated test should not assign directly: foreign keys and auto-managed
// given plausible values here), the auto-managed "created_at"/"updated_at" columns, and the // updated_at.
// soft-delete columns (which SoftDeleteXyz() manages).
func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) { func UpdateTestFields(tbl pkgschema.Table) (ret []pkgschema.Column) {
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() _, hasUpdatedAt := tbl.HasAutoTimestamps()
hasSoftDelete := tbl.HasSoftDelete() for _, c := range tbl.GetUpdateableColumns() {
for _, c := range tbl.Columns { if c.IsForeignKey {
if c.Name == "rowid" || c.IsPrimaryKey || c.IsForeignKey {
continue
}
if c.Name == "created_at" && hasCreatedAt {
continue continue
} }
if c.Name == "updated_at" && hasUpdatedAt { if c.Name == "updated_at" && hasUpdatedAt {
continue continue
} }
if hasSoftDelete && (c.Name == "is_deleted" || c.Name == "deleted_at") {
continue
}
ret = append(ret, c) ret = append(ret, c)
} }
return return
@@ -101,12 +93,55 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps() hasCreatedAt, hasUpdatedAt := tbl.HasAutoTimestamps()
updateTestFields := UpdateTestFields(tbl) updateTestFields := UpdateTestFields(tbl)
uniqueColumns := map[string]bool{}
for _, index := range schema.Indexes {
if index.TableName != tbl.TableName || !index.IsUnique {
continue
}
for _, columnName := range index.Columns {
uniqueColumns[columnName] = true
}
}
hasRandomUniqueField := false
for _, c := range updateTestFields {
if uniqueColumns[c.Name] {
hasRandomUniqueField = true
break
}
}
requiredForeignKeys := []pkgschema.Column{}
for _, c := range tbl.Columns {
// Only ordinary object foreign keys use a distinct TargetObjectID Go type;
// code-table foreign keys retain their underlying SQLite type in the generated model.
if c.IsNotNull && c.IsNonCodeTableForeignKey() {
requiredForeignKeys = append(requiredForeignKeys, c)
}
}
requiredForeignKeyParamNames := paramNamesFor(requiredForeignKeys)
// Calls made by this generated test do not create related objects themselves. Pass a
// zero ID so the generated test remains syntactically valid; callers of MakeXyz provide
// IDs of the related objects they create.
makeItemArgs := func() (ret []ast.Expr) {
for range requiredForeignKeys {
ret = append(ret, &ast.BasicLit{Kind: token.INT, Value: "0"})
}
return
}()
// func MakeItem() Item { return Item{} } // func MakeItem() Item { return Item{} }
makeItemFunc := &ast.FuncDecl{ makeItemFunc := &ast.FuncDecl{
Name: makeHelperName, Name: makeHelperName,
Type: &ast.FuncType{ Type: &ast.FuncType{
Params: &ast.FieldList{}, Params: &ast.FieldList{List: func() (ret []*ast.Field) {
for i, c := range requiredForeignKeys {
ret = append(ret, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(requiredForeignKeyParamNames[i])},
Type: GoTypeForColumn(c),
})
}
return
}()},
Results: &ast.FieldList{ Results: &ast.FieldList{
List: []*ast.Field{ List: []*ast.Field{
{Type: ast.NewIdent(tbl.GoTypeName)}, {Type: ast.NewIdent(tbl.GoTypeName)},
@@ -114,24 +149,71 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
}, },
Body: &ast.BlockStmt{ Body: &ast.BlockStmt{
List: []ast.Stmt{ List: append(
&ast.ReturnStmt{ func() []ast.Stmt {
Results: []ast.Expr{ if !hasRandomUniqueField {
&ast.CompositeLit{ return nil
Type: ast.NewIdent(tbl.GoTypeName), }
Elts: func() (ret []ast.Expr) { return []ast.Stmt{&ast.AssignStmt{
for _, c := range updateTestFields { Lhs: []ast.Expr{ast.NewIdent("id")},
ret = append(ret, &ast.KeyValueExpr{ Tok: token.DEFINE,
Key: ast.NewIdent(c.GoFieldName()), Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("rand"), Sel: ast.NewIdent("Int")}}},
Value: SampleValue(c, 0), }}
}) }(),
} []ast.Stmt{
return &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
}(),
},
}, },
}, },
}, }...,
}, ),
}, },
} }
@@ -242,7 +324,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Tok: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[isFirst], Tok: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[isFirst],
Rhs: []ast.Expr{ Rhs: []ast.Expr{
&ast.CallExpr{ &ast.CallExpr{
Fun: ast.NewIdent("Make" + tbl.GoTypeName), Fun: ast.NewIdent("Make" + tbl.GoTypeName),
Args: makeItemArgs,
}, },
}, },
}, },
@@ -329,7 +412,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{testObj}, Lhs: []ast.Expr{testObj},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}}, Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: makeItemArgs}},
}, },
// item.Description = <sample value, offset 1> // item.Description = <sample value, offset 1>
// ...one assignment per updatable field // ...one assignment per updatable field
@@ -395,39 +478,41 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
makeDeepEqual(testObj, testObj2), makeDeepEqual(testObj, testObj2),
) )
stmts = append(stmts, if len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
BlankLine(), stmts = append(stmts,
Comment("Update"), BlankLine(),
) Comment("Update"),
)
// item.Description = <sample value, offset 2> // item.Description = <sample value, offset 2>
// ...one assignment per updatable field // ...one assignment per updatable field
for _, c := range updateTestFields { for _, c := range updateTestFields {
stmts = append(stmts, &ast.AssignStmt{ stmts = append(stmts, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}}, Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent(c.GoFieldName())}},
Tok: token.ASSIGN, Tok: token.ASSIGN,
Rhs: []ast.Expr{SampleValue(c, 2)}, Rhs: []ast.Expr{SampleValue(c, 2)},
}) })
}
stmts = append(stmts,
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item2 = must.Get(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
},
// if deep.Equal(...) {...}
makeDeepEqual(testObj, testObj2),
)
} }
stmts = append(stmts,
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: testDB, Sel: ast.NewIdent("Save" + tbl.GoTypeName)},
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item2 = must.Get(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{mustCall(getItemByPKCall(testObj))},
},
// if deep.Equal(...) {...}
makeDeepEqual(testObj, testObj2),
)
indexGets, hasIndexedGets := []ast.Stmt{ indexGets, hasIndexedGets := []ast.Stmt{
BlankLine(), BlankLine(),
Comment("Indexed lookups"), Comment("Indexed lookups"),
@@ -560,23 +645,33 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Decls: append([]ast.Decl{ Decls: append([]ast.Decl{
&ast.GenDecl{ &ast.GenDecl{
Tok: token.IMPORT, Tok: token.IMPORT,
Specs: []ast.Spec{ Specs: func() []ast.Spec {
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}}, specs := []ast.Spec{}
&ast.ImportSpec{ if hasRandomUniqueField {
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`}, specs = append(specs,
Name: ast.NewIdent("."), &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
}, &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"math/rand"`}},
&ast.ImportSpec{ )
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`}, }
}, specs = append(specs, []ast.Spec{
&ast.ImportSpec{ &ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)}, &ast.ImportSpec{
Name: ast.NewIdent("."), 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: `"github.com/go-test/deep"`}}, },
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}}, &ast.ImportSpec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}}, Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/must"`},
}, },
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/go-test/deep"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
}...)
return specs
}(),
}, },
}, testList...), }, testList...),
} }

View File

@@ -17,18 +17,18 @@ type DBConfig struct {
// is_initialized bool // is_initialized bool
// The SQL schema of the database under management // The SQL schema of the database under management
sql_schema *string sqlSchema *string
// Database starts at version 0. First migration brings us to version 1 // Database starts at version 0. First migration brings us to version 1
migrations *[]Migration migrations *[]Migration
version_number uint versionNumber uint
} }
func Init(schema *string, migrationsList *[]Migration) DBConfig { func Init(schema *string, migrationsList *[]Migration) DBConfig {
return DBConfig{ return DBConfig{
sql_schema: schema, sqlSchema: schema,
migrations: migrationsList, migrations: migrationsList,
version_number: uint(len(*migrationsList)), versionNumber: uint(len(*migrationsList)),
} }
} }
@@ -52,7 +52,7 @@ func (c DBConfig) Create(path string) (*sqlx.DB, error) {
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil { if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
return nil, fmt.Errorf("running pragma statements: %w", err) return nil, fmt.Errorf("running pragma statements: %w", err)
} }
if _, err = db.Exec(*c.sql_schema); err != nil { if _, err = db.Exec(*c.sqlSchema); err != nil {
return nil, fmt.Errorf("creating schema: %w", err) return nil, fmt.Errorf("creating schema: %w", err)
} }
@@ -79,18 +79,18 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
return fmt.Errorf("couldn't check database version: %w", err) return fmt.Errorf("couldn't check database version: %w", err)
} }
if version > c.version_number { if version > c.versionNumber {
return VersionMismatchError{c.version_number, version} return VersionMismatchError{c.versionNumber, version}
} }
if c.version_number > version { if c.versionNumber > version {
fmt.Print(textutils.ColorYellow) fmt.Print(textutils.ColorYellow)
fmt.Printf("================================================\n") fmt.Printf("================================================\n")
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version, fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
c.version_number) c.versionNumber)
fmt.Print(textutils.ColorReset) fmt.Print(textutils.ColorReset)
for i := version; i < c.version_number; i++ { for i := version; i < c.versionNumber; i++ {
m := (*c.migrations)[i] m := (*c.migrations)[i]
if err := m.Apply(db); err != nil { if err := m.Apply(db); err != nil {
return fmt.Errorf("migration %d: %w", m.ID, err) return fmt.Errorf("migration %d: %w", m.ID, err)
@@ -103,7 +103,7 @@ func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
fmt.Print(textutils.ColorGreen) fmt.Print(textutils.ColorGreen)
fmt.Printf("================================================\n") fmt.Printf("================================================\n")
fmt.Printf("Database version has been upgraded to version %d.\n", c.version_number) fmt.Printf("Database version has been upgraded to version %d.\n", c.versionNumber)
fmt.Print(textutils.ColorReset) fmt.Print(textutils.ColorReset)
} }

View File

@@ -15,17 +15,17 @@ import (
func TestCreateAndConnectToDB(t *testing.T) { func TestCreateAndConnectToDB(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
_schema_sql, err := os.ReadFile("../../sample_data/test_schemas/food.sql") _schemaSQL, err := os.ReadFile("../../sample_data/test_schemas/food.sql")
require.NoError(t, err) require.NoError(t, err)
schema_sql := string(_schema_sql) schemaSQL := string(_schemaSQL)
migrations := []db.Migration{} migrations := []db.Migration{}
config := db.Init(&schema_sql, &migrations) config := db.Init(&schemaSQL, &migrations)
data_dir := "../../sample_data/data" dataDir := "../../sample_data/data"
_ = os.MkdirAll(data_dir, os.FileMode(0o644)) _ = os.MkdirAll(dataDir, os.FileMode(0o644))
// Create then connect to a new empty DB // Create then connect to a new empty DB
dbPath := filepath.Join(data_dir, "test.db") dbPath := filepath.Join(dataDir, "test.db")
_ = os.Remove(dbPath) // Delete it if it exists _ = os.Remove(dbPath) // Delete it if it exists
_, err = config.Create(dbPath) _, err = config.Create(dbPath)
@@ -38,18 +38,18 @@ func TestVersionUpgrade(t *testing.T) {
require := require.New(t) require := require.New(t)
// Create a little schema // Create a little schema
initial_schema := ` initialSchema := `
create table items(rowid integer primary key); create table items(rowid integer primary key);
create table db_version (version integer primary key) strict, without rowid; create table db_version (version integer primary key) strict, without rowid;
insert into db_version values(0); insert into db_version values(0);
` `
migrations := []db.Migration{} migrations := []db.Migration{}
config := db.Init(&initial_schema, &migrations) config := db.Init(&initialSchema, &migrations)
connection, err := config.Create(":memory:") connection, err := config.Create(":memory:")
require.NoError(err) require.NoError(err)
get_version := func(c *sqlx.DB) (ret int) { getVersion := func(c *sqlx.DB) (ret int) {
// TODO: this should be a function exposed by the `db` package itself // TODO: this should be a function exposed by the `db` package itself
require.NoError(c.Get(&ret, "select version from db_version")) require.NoError(c.Get(&ret, "select version from db_version"))
return return
@@ -58,21 +58,21 @@ func TestVersionUpgrade(t *testing.T) {
var items []int var items []int
require.NoError(connection.Select(&items, "select * from items")) require.NoError(connection.Select(&items, "select * from items"))
require.Len(items, 0) require.Len(items, 0)
require.Equal(0, get_version(connection)) require.Equal(0, getVersion(connection))
// Create a migration to add a new Item // Create a migration to add a new Item
migrations = append(migrations, db.Migration{ID: 1, SQL: "insert into items (rowid) values (1)"}) migrations = append(migrations, db.Migration{ID: 1, SQL: "insert into items (rowid) values (1)"})
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration config = db.Init(&initialSchema, &migrations) // Reinitialize with the new migration
require.NoError(config.CheckAndUpdateVersion(connection)) require.NoError(config.CheckAndUpdateVersion(connection))
var items2 []int var items2 []int
require.NoError(connection.Select(&items2, "select * from items")) require.NoError(connection.Select(&items2, "select * from items"))
require.Len(items2, 1) require.Len(items2, 1)
require.Equal(1, get_version(connection)) require.Equal(1, getVersion(connection))
// Create a migration to add a new Item // Create a migration to add a new Item
migrations = append(migrations, db.Migration{ID: 2, SQL: `alter table items add column name string default 'asdf'`}) migrations = append(migrations, db.Migration{ID: 2, SQL: `alter table items add column name string default 'asdf'`})
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration config = db.Init(&initialSchema, &migrations) // Reinitialize with the new migration
require.NoError(config.CheckAndUpdateVersion(connection)) require.NoError(config.CheckAndUpdateVersion(connection))
var items3 []struct { var items3 []struct {
@@ -82,5 +82,5 @@ func TestVersionUpgrade(t *testing.T) {
require.NoError(connection.Select(&items3, "select * from items")) require.NoError(connection.Select(&items3, "select * from items"))
require.Len(items2, 1) require.Len(items2, 1)
assert.Equal(t, "asdf", items3[0].Name) assert.Equal(t, "asdf", items3[0].Name)
require.Equal(2, get_version(connection)) require.Equal(2, getVersion(connection))
} }

View File

@@ -39,9 +39,9 @@ func assertForeignKeysAreGood(t *testing.T, conn *sqlx.DB) {
t.Helper() t.Helper()
// FKs should be enabled and intact at all times // FKs should be enabled and intact at all times
var fkEnabled bool var isFkEnabled bool
require.NoError(t, conn.Get(&fkEnabled, `pragma foreign_keys`)) require.NoError(t, conn.Get(&isFkEnabled, `pragma foreign_keys`))
assert.True(t, fkEnabled) assert.True(t, isFkEnabled)
assert.NoError(t, db.PragmaForeignKeyCheck(conn)) assert.NoError(t, db.PragmaForeignKeyCheck(conn))
// ...and enforcement should be in effect again, not merely reported as on // ...and enforcement should be in effect again, not merely reported as on

51
pkg/db/vsc.go Normal file
View File

@@ -0,0 +1,51 @@
package db
import (
"database/sql/driver"
"encoding/json"
"errors"
"strings"
)
const separator = "\x1f"
type VSC []string
// MarshalJSON replaces `nil` with `[]` for hygiene.
func (vs VSC) MarshalJSON() ([]byte, error) {
if vs == nil {
vs = VSC{}
}
return json.Marshal([]string(vs))
}
func (vs VSC) Value() (driver.Value, error) {
fields := make([]string, len(vs))
for i, v := range vs {
fields[i] = strings.ReplaceAll(v, separator, "")
}
return strings.Join(fields, separator), nil
}
var ErrWrongVSCType = errors.New("incompatible DB type for VSC")
func (vs *VSC) Scan(src any) error {
var s string
switch v := src.(type) {
case string:
s = v
case []byte:
s = string(v)
case nil:
*vs = nil
return nil
default:
return ErrWrongVSCType
}
if s == "" {
*vs = VSC{}
return nil
}
*vs = strings.Split(s, separator)
return nil
}

48
pkg/db/vsc_test.go Normal file
View File

@@ -0,0 +1,48 @@
package db_test
import (
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/require"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
)
func TestVSCDBRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, conn.Close()) })
_, err = conn.Exec(`create table vsc_items (id integer primary key, items text not null)`)
require.NoError(t, err)
type vscRow struct {
ID int `db:"id"`
Items db.VSC `db:"items"`
}
for _, want := range []vscRow{
{ID: 1, Items: db.VSC{"email"}},
{ID: 2, Items: db.VSC{"email", "profile"}},
{ID: 3, Items: db.VSC{}},
} {
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
require.NoError(t, err)
var got vscRow
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
require.Equal(t, want, got)
}
t.Run("nil gets upgrade to empty list", func(t *testing.T) {
want := vscRow{ID: 4, Items: nil}
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
require.NoError(t, err)
var got vscRow
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
require.NotNil(t, got.Items)
require.Equal(t, db.VSC{}, got.Items)
})
}

76
pkg/generic/mutexmap.go Normal file
View File

@@ -0,0 +1,76 @@
package generic
import (
"maps"
"sync"
)
type MutexMap[K comparable, V any] struct {
mu sync.RWMutex
contents map[K]V
}
func NewMutexMap[K comparable, V any]() *MutexMap[K, V] {
return &MutexMap[K, V]{contents: make(map[K]V)}
}
func (m *MutexMap[K, V]) Set(key K, value V) {
m.mu.Lock()
defer m.mu.Unlock()
m.contents[key] = value
}
func (m *MutexMap[K, V]) Get(key K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.contents[key]
return v, ok
}
func (m *MutexMap[K, V]) Len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.contents)
}
// Upd atomically updates the value for key. `fn` is used to determine how to update an existing
// value, if there is one. It will be passed the existing value, and should return `(newValue V, shouldReplace bool)`.
//
// Returns the value that ends up associated with key after the call and whether an update occurred.
func (m *MutexMap[K, V]) Upd(key K, fn func(prev V) (next V, shouldReplace bool)) (V, bool) {
m.mu.Lock()
defer m.mu.Unlock()
prev := m.contents[key]
if next, shouldReplace := fn(prev); shouldReplace {
m.contents[key] = next
return next, true
}
return prev, false
}
// Del removes key from the map. Returns true if the key was present.
func (m *MutexMap[K, V]) Del(key K) bool { //nolint: boolnames // Return value is incidental, not the main purpose of the function
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.contents[key]; !ok {
return false
}
delete(m.contents, key)
return true
}
func (m *MutexMap[K, V]) Cpy() map[K]V {
m.mu.RLock()
defer m.mu.RUnlock()
cp := make(map[K]V, len(m.contents))
maps.Copy(cp, m.contents)
return cp
}
func (m *MutexMap[K, V]) Pop(key K) (ret V, isOk bool) {
m.mu.Lock()
defer m.mu.Unlock()
ret, isOk = m.contents[key]
delete(m.contents, key)
return
}

View File

@@ -16,8 +16,8 @@ func Get[T any](val T, err error) T {
return val return val
} }
func Be(condition bool, msg string, args ...any) { func Be(ok bool, msg string, args ...any) {
if !condition { if !ok {
panic(fmt.Errorf(msg, args...)) //nolint:err113 // not a returned error panic(fmt.Errorf(msg, args...)) //nolint:err113 // not a returned error
} }
} }

View File

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

View File

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

View File

@@ -15,12 +15,12 @@ import (
) )
//go:embed views.sql //go:embed views.sql
var create_views string var createViews string
// InitDB creates an in-memory DB from a given schema string. // InitDB creates an in-memory DB from a given schema string.
func InitDB(sql_schema string) *sqlx.DB { func InitDB(sqlSchema string) *sqlx.DB {
db := sqlx.MustOpen("sqlite3", ":memory:") db := sqlx.MustOpen("sqlite3", ":memory:")
db.MustExec(sql_schema) db.MustExec(sqlSchema)
return db return db
} }
@@ -35,7 +35,7 @@ func SchemaFromSQLFile(filepath string) (Schema, error) {
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema. // SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
func SchemaFromDB(db *sqlx.DB) Schema { func SchemaFromDB(db *sqlx.DB) Schema {
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}} ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
db.MustExec(create_views) db.MustExec(createViews)
var tables []Table var tables []Table
must.Do(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`)) must.Do(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))

View File

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

View File

@@ -76,3 +76,21 @@ func (t Table) HasSoftDelete() bool {
} }
return hasIsDeleted && hasDeletedAt 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)
}
return
}

34
pkg/schema/table_test.go Normal file
View File

@@ -0,0 +1,34 @@
package schema
import "testing"
func TestTableGetUpdateableColumns(t *testing.T) {
tbl := Table{
Columns: []Column{
{Name: "rowid"},
{Name: "version", Type: "integer", IsPrimaryKey: true},
{Name: "created_at", Type: "integer"},
{Name: "updated_at", Type: "integer"},
{Name: "is_deleted", Type: "integer"},
{Name: "deleted_at", Type: "integer"},
{Name: "owner_id", Type: "integer", IsForeignKey: true},
{Name: "name", Type: "text"},
},
}
gotColumns := tbl.GetUpdateableColumns()
got := make([]string, 0, len(gotColumns))
for _, col := range gotColumns {
got = append(got, col.Name)
}
want := []string{"updated_at", "owner_id", "name"}
if len(got) != len(want) {
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
}
}
}