Compare commits
9 Commits
f98f8416e8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 12fc338947 | |||
| c70732d6af | |||
| ef71828ce2 | |||
| f8d79761f4 | |||
| de11a0214b | |||
| 936df98cc4 | |||
| 56b264fcb3 | |||
| 99b57c88b0 | |||
| 6cf0b66dd5 |
10
.custom-gcl.yaml
Normal file
10
.custom-gcl.yaml
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ sample_data/data
|
|||||||
.cmd-old/
|
.cmd-old/
|
||||||
pkg/.testapp
|
pkg/.testapp
|
||||||
.claude
|
.claude
|
||||||
|
bin
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
14
cmd/main.go
14
cmd/main.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
12
doc/TODO.txt
12
doc/TODO.txt
@@ -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"
|
||||||
|
|||||||
@@ -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
7
doc/todo/admin-pages.md
Normal 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
39
ops/build-lint.sh
Executable 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
16
ops/lint.sh
Executable 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 $@
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
go test -tags fts5 ./...
|
go test -tags fts5 ./... $@
|
||||||
|
|||||||
@@ -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 "),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -486,6 +478,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
makeDeepEqual(testObj, testObj2),
|
makeDeepEqual(testObj, testObj2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(tbl.GetUpdateableColumns()) > 0 { // Skip if no mutable fields
|
||||||
stmts = append(stmts,
|
stmts = append(stmts,
|
||||||
BlankLine(),
|
BlankLine(),
|
||||||
Comment("Update"),
|
Comment("Update"),
|
||||||
@@ -518,6 +511,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
|
|||||||
// if deep.Equal(...) {...}
|
// if deep.Equal(...) {...}
|
||||||
makeDeepEqual(testObj, testObj2),
|
makeDeepEqual(testObj, testObj2),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
indexGets, hasIndexedGets := []ast.Stmt{
|
indexGets, hasIndexedGets := []ast.Stmt{
|
||||||
BlankLine(),
|
BlankLine(),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
51
pkg/db/vsc.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const separator = "\x1f"
|
||||||
|
|
||||||
|
type VSC []string
|
||||||
|
|
||||||
|
// MarshalJSON replaces `nil` with `[]` for hygiene.
|
||||||
|
func (vs VSC) MarshalJSON() ([]byte, error) {
|
||||||
|
if vs == nil {
|
||||||
|
vs = VSC{}
|
||||||
|
}
|
||||||
|
return json.Marshal([]string(vs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs VSC) Value() (driver.Value, error) {
|
||||||
|
fields := make([]string, len(vs))
|
||||||
|
for i, v := range vs {
|
||||||
|
fields[i] = strings.ReplaceAll(v, separator, "")
|
||||||
|
}
|
||||||
|
return strings.Join(fields, separator), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrWrongVSCType = errors.New("incompatible DB type for VSC")
|
||||||
|
|
||||||
|
func (vs *VSC) Scan(src any) error {
|
||||||
|
var s string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
case nil:
|
||||||
|
*vs = nil
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrWrongVSCType
|
||||||
|
}
|
||||||
|
if s == "" {
|
||||||
|
*vs = VSC{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*vs = strings.Split(s, separator)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
pkg/db/vsc_test.go
Normal file
48
pkg/db/vsc_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVSCDBRoundTrip(t *testing.T) {
|
||||||
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, conn.Close()) })
|
||||||
|
|
||||||
|
_, err = conn.Exec(`create table vsc_items (id integer primary key, items text not null)`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
type vscRow struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Items db.VSC `db:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []vscRow{
|
||||||
|
{ID: 1, Items: db.VSC{"email"}},
|
||||||
|
{ID: 2, Items: db.VSC{"email", "profile"}},
|
||||||
|
{ID: 3, Items: db.VSC{}},
|
||||||
|
} {
|
||||||
|
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got vscRow
|
||||||
|
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||||
|
require.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
t.Run("nil gets upgrade to empty list", func(t *testing.T) {
|
||||||
|
want := vscRow{ID: 4, Items: nil}
|
||||||
|
_, err = conn.NamedExec(`insert into vsc_items (id, items) values (:id, :items)`, want)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got vscRow
|
||||||
|
require.NoError(t, conn.Get(&got, `select id, items from vsc_items where id = ?`, want.ID))
|
||||||
|
require.NotNil(t, got.Items)
|
||||||
|
require.Equal(t, db.VSC{}, got.Items)
|
||||||
|
})
|
||||||
|
}
|
||||||
76
pkg/generic/mutexmap.go
Normal file
76
pkg/generic/mutexmap.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package generic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MutexMap[K comparable, V any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
contents map[K]V
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMutexMap[K comparable, V any]() *MutexMap[K, V] {
|
||||||
|
return &MutexMap[K, V]{contents: make(map[K]V)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Set(key K, value V) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.contents[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Get(key K) (V, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
v, ok := m.contents[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Len() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upd atomically updates the value for key. `fn` is used to determine how to update an existing
|
||||||
|
// value, if there is one. It will be passed the existing value, and should return `(newValue V, shouldReplace bool)`.
|
||||||
|
//
|
||||||
|
// Returns the value that ends up associated with key after the call and whether an update occurred.
|
||||||
|
func (m *MutexMap[K, V]) Upd(key K, fn func(prev V) (next V, shouldReplace bool)) (V, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
prev := m.contents[key]
|
||||||
|
if next, shouldReplace := fn(prev); shouldReplace {
|
||||||
|
m.contents[key] = next
|
||||||
|
return next, true
|
||||||
|
}
|
||||||
|
return prev, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del removes key from the map. Returns true if the key was present.
|
||||||
|
func (m *MutexMap[K, V]) Del(key K) bool { //nolint: boolnames // Return value is incidental, not the main purpose of the function
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if _, ok := m.contents[key]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(m.contents, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Cpy() map[K]V {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
cp := make(map[K]V, len(m.contents))
|
||||||
|
maps.Copy(cp, m.contents)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MutexMap[K, V]) Pop(key K) (ret V, isOk bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
ret, isOk = m.contents[key]
|
||||||
|
delete(m.contents, key)
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
34
pkg/schema/table_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTableGetUpdateableColumns(t *testing.T) {
|
||||||
|
tbl := Table{
|
||||||
|
Columns: []Column{
|
||||||
|
{Name: "rowid"},
|
||||||
|
{Name: "version", Type: "integer", IsPrimaryKey: true},
|
||||||
|
{Name: "created_at", Type: "integer"},
|
||||||
|
{Name: "updated_at", Type: "integer"},
|
||||||
|
{Name: "is_deleted", Type: "integer"},
|
||||||
|
{Name: "deleted_at", Type: "integer"},
|
||||||
|
{Name: "owner_id", Type: "integer", IsForeignKey: true},
|
||||||
|
{Name: "name", Type: "text"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotColumns := tbl.GetUpdateableColumns()
|
||||||
|
got := make([]string, 0, len(gotColumns))
|
||||||
|
for _, col := range gotColumns {
|
||||||
|
got = append(got, col.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"updated_at", "owner_id", "name"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("GetUpdateableColumns() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user