64 Commits

Author SHA1 Message Date
wispem-wantex
c08f12e275 ci: add fts5 build tag for tests
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 11s
2026-01-17 09:04:09 +09:00
wispem-wantex
0371fb4144 sqlite_lint: add support for FTS5 and virtual tables
Some checks failed
CI / build-docker (push) Successful in 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 6s
2026-01-17 09:00:24 +09:00
d70cbc1913 doc: add instructions to run sqlite_lint 2026-01-14 09:00:20 +09:00
ec19c3dcb5 doc: add entries for new 'rowid' related sqlite lint checks 2026-01-14 09:00:20 +09:00
2df2c634cd doc: add doc page for sqlite_lint 2026-01-14 08:22:28 +09:00
45c9e15dd3 doc: add some more notes to the main info doc file
All checks were successful
CI / build-docker (push) Successful in 15s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 40s
2026-01-10 17:39:08 -08:00
dc33bd84ff docs: add a TODO; also add IsSqlitePrimaryKeyError and change ForeignKey interface to '~int'
All checks were successful
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 31s
2026-01-10 17:19:14 -08:00
2d29ce92ec generator: make scaffolded db schema pass new sqlite_lint checks
All checks were successful
CI / build-docker (push) Successful in 8s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 32s
2026-01-10 17:10:48 -08:00
a6cbbd3b39 tests: add test files for previous commit
Some checks failed
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 31s
2026-01-10 17:06:28 -08:00
2ac9d8e775 sqlite_lint: add lint checks for rowid and without rowid
Some checks failed
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 15s
2026-01-10 17:04:27 -08:00
560e461b00 lint: fixes
All checks were successful
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 14s
2026-01-10 16:09:41 -08:00
5c1e1dcb6a generator: add GetAllXyzs() function
Some checks failed
CI / build-docker (push) Successful in 6s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 8s
2026-01-10 16:06:48 -08:00
a05cad5144 lint: fix lll
All checks were successful
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 11s
2026-01-10 15:30:39 -08:00
e6a8175644 generator: use 'Must' flow helper in generated query funcs
Some checks failed
CI / build-docker (push) Successful in 47s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 4s
2026-01-10 15:28:09 -08:00
195c8f980e ci: make container build handle being built as 'root' correctly
Some checks failed
CI / build-docker (push) Failing after 1s
CI / build-docker-bootstrap (push) Successful in 42s
CI / release-test (push) Successful in 3m48s
2026-01-10 14:05:54 -08:00
8b674a0dea ci: add sudo to bootstrap build container, in accordance with previous commit
Some checks failed
CI / build-docker (push) Failing after 1s
CI / build-docker-bootstrap (push) Failing after 16s
CI / release-test (push) Has been skipped
2026-01-10 13:52:54 -08:00
13a6255553 ci: use devcontainer build script in CI. Also re-tag it as 'gas' instead of 'gas/ci' (which should be redundant)
Some checks failed
CI / build-docker (push) Failing after 0s
CI / build-docker-bootstrap (push) Failing after 14s
CI / release-test (push) Has been skipped
2026-01-10 13:51:26 -08:00
e0e6046afe devcontainer: add doc, user to match host machine, and scripts to build/start
Some checks failed
CI / build-docker (push) Failing after 0s
CI / build-docker-bootstrap (push) Failing after 1m40s
CI / release-test (push) Has been skipped
2026-01-10 13:39:41 -08:00
381d648be0 doc: remove some completed TODO items 2025-12-12 23:19:44 -08:00
ded07eb182 model generator: add the 'const xyzSQLFields = ...' decl, use it in the select query for GetXyzByID
All checks were successful
CI / build-docker (push) Successful in 10s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 31s
2025-12-12 23:19:14 -08:00
a32bf873c9 refactor: create helper function for computing a table's generated type-name 2025-12-12 22:37:29 -08:00
d9df69eccf fix: correct lint errors in the generated sqlite schema
All checks were successful
CI / build-docker (push) Successful in 8s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 29s
2025-12-12 22:11:10 -08:00
89db9e14e2 fix: parser no longer breaks on encountering indexes that contain expression columns
All checks were successful
CI / build-docker (push) Successful in 16s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 47s
2025-12-12 21:54:46 -08:00
f74da53c37 refactor: use flowutils helpers instead of 'if err != nil' 2025-12-12 21:52:59 -08:00
6358d12d10 codegen: fix another typo in filename
All checks were successful
CI / build-docker (push) Successful in 18s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 55s
2025-11-09 16:53:33 -08:00
1cbc99307a codegen: fix typo in template file name
Some checks failed
CI / build-docker (push) Successful in 12s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 1m9s
2025-11-09 16:48:13 -08:00
fef3f11342 Run the code generator integration test in CI
Some checks failed
CI / build-docker (push) Successful in 10s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 31s
2025-11-09 16:44:59 -08:00
208c1eb26c Add model generator based on AST
All checks were successful
CI / build-docker (push) Successful in 13s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 40s
2025-11-09 16:37:01 -08:00
2146e1de77 test: add integration test for 'gas init'
All checks were successful
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 20s
2025-11-09 00:03:30 -08:00
a81dfb69a5 db: remove implicit singleton pattern, move functions under a struct object
All checks were successful
CI / build-docker (push) Successful in 7s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 25s
2025-11-09 00:01:15 -08:00
bfb7073cf6 lint: fix shell lint errors
All checks were successful
CI / build-docker (push) Successful in 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 17s
2025-11-08 23:57:37 -08:00
76f674d63b ci: add shellcheck step
Some checks failed
CI / build-docker (push) Successful in 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 10s
2025-11-08 23:56:31 -08:00
d1c00f9455 Run main build if backup (bootstrap) container build step is skipped
All checks were successful
CI / build-docker (push) Successful in 12s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 26s
2025-11-08 23:29:40 -08:00
23dc2c7899 Improve job sequence dependencies some more
All checks were successful
CI / build-docker (push) Successful in 8s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Has been skipped
2025-11-08 23:17:31 -08:00
d0f919d69a Add bash to CI container
Some checks failed
CI / build-docker (push) Failing after 5s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Has been skipped
2025-11-08 23:14:19 -08:00
8a1b7a9f34 Improve CI job dependency / ordering conditions
Some checks failed
CI / build-docker (push) Failing after 10s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Has been skipped
2025-11-08 23:13:00 -08:00
fef24e2364 Add more specific debugging to failing step
Some checks failed
CI / build-docker (push) Failing after 7s
CI / release-test (push) Failing after 16s
CI / build-docker-bootstrap (push) Has been skipped
2025-11-08 23:09:51 -08:00
61fa4e5cc4 Fix: apparently double quotes are invalid around variables in gitea expressions
Some checks failed
CI / build-docker (push) Failing after 9s
CI / release-test (push) Failing after 25s
CI / build-docker-bootstrap (push) Has been skipped
2025-11-08 23:08:48 -08:00
22ee5e181a Add debugging to failing CI step
Some checks failed
CI / build-docker (push) Failing after 8s
CI / release-test (push) Failing after 25s
CI / build-docker-bootstrap (push) Failing after 0s
2025-11-08 23:06:37 -08:00
be351bbb7d Add cheating bootstrap CI container, with gocheckout pre-installed
Some checks failed
CI / build-docker (push) Failing after 8s
CI / release-test (push) Failing after 19s
CI / build-docker-bootstrap (push) Failing after 1s
2025-11-08 23:05:38 -08:00
4c266ce336 Bump gocheckout version, and install it in the CI container
Some checks failed
CI / build-docker (push) Successful in 46s
CI / release-test (push) Failing after 9s
2025-11-08 22:27:41 -08:00
da85d89e9b Install Go for bootstrap CI container
Some checks failed
CI / build-docker (push) Successful in 2m3s
CI / release-test (push) Failing after 10s
2025-11-08 22:09:45 -08:00
98c6875262 Build CI container in CI
Some checks failed
CI / build-docker (push) Failing after 5s
CI / release-test (push) Failing after 1s
2025-11-08 22:08:32 -08:00
912210cc90 Enable no-dot-import lint check
All checks were successful
CI / release-test (push) Successful in 1m36s
2025-11-08 19:59:59 -08:00
dbde3c8724 Avoid panicking in db.Connect and db.Create, as they are library functions 2025-11-08 19:36:55 -08:00
b7b581ff5a Add Timestamp type for db 2025-11-08 19:36:10 -08:00
6a837d28c6 Add db/errors.go, move error definitions to it 2025-11-08 19:23:38 -08:00
8c6d8354df Add 'ops/' directory and sqlite_lint integration test
All checks were successful
CI / release-test (push) Successful in 24s
2025-11-08 17:19:11 -08:00
6624b5794f Add 3-letter names reference 2025-08-24 14:51:57 -07:00
2c8b842f17 Add package initialization subcommand and package
All checks were successful
CI / release-test (push) Successful in 26s
2025-08-24 14:47:37 -07:00
0bc951748d Add a 'doc/links.txt' file 2025-08-24 14:44:22 -07:00
5a0ecdec1c Update go.mod with new cobra dependency
All checks were successful
CI / release-test (push) Successful in 13s
2025-08-24 11:38:24 -07:00
e888fe0ec1 Add main program with 'sqlite_lint' subcommand
Some checks failed
CI / release-test (push) Failing after 16s
2025-08-24 11:37:23 -07:00
c402c775a3 Add flowutils
All checks were successful
CI / release-test (push) Successful in 16s
2025-08-23 20:24:34 -07:00
87f5471b76 Add doc folder 2025-08-23 19:00:51 -07:00
7449e7dc10 Fix lint error
All checks were successful
CI / release-test (push) Successful in 16s
2025-08-23 18:57:13 -07:00
100b53d799 Add Schema definition changes to support the linter functions
Some checks failed
CI / release-test (push) Failing after 16s
2025-08-23 18:55:13 -07:00
8b422ada11 Add sqlite-lint to 'pkg/schema/lint'
Some checks failed
CI / release-test (push) Failing after 10s
2025-08-23 18:54:00 -07:00
a4de16a1dd Fix lint errors
All checks were successful
CI / release-test (push) Successful in 49s
2025-08-23 18:46:27 -07:00
53785b6e6e Add lint config and build step
Some checks failed
CI / release-test (push) Failing after 38s
2025-08-23 18:19:22 -07:00
8245f89305 Add parsing of schema indexes
All checks were successful
CI / release-test (push) Successful in 12s
2025-08-23 16:58:41 -07:00
e7f9934f03 SQL whitespace 2025-08-23 15:59:08 -07:00
14d395f0ea Tidy up the Table SQL interface a bit
All checks were successful
CI / release-test (push) Successful in 2m33s
2025-08-23 15:53:23 -07:00
754c21ab29 Move sql views to a .sql file 2025-08-23 14:10:57 -07:00
52 changed files with 2517 additions and 92 deletions

View File

@@ -3,9 +3,35 @@ name: CI
on: [push]
jobs:
release-test:
# These steps build the `gas` docker image.
# There's a tricky bootstrapping problem here. We'll try to cheat first, and if that doesn't
# work, then we'll do a full bootstrapping build starting from Alpine and installing `gocheckout`
# first thing.
build-docker:
continue-on-error: true
container:
image: offline-twitter/go
image: gas
volumes:
- woodpecker-gocache:/go-cache-volume
env:
GOPATH: /go-cache-volume
GOCACHE: /go-cache-volume/build-cache
steps:
- name: checkout
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gocheckout
- name: build CI container
run: ops/devcontainer/build.sh
# Fallback: full bootstrap build. This one is quite slow.
build-docker-bootstrap:
needs: build-docker
if: failure()
container:
image: alpine:3.22
volumes:
- woodpecker-gocache:/go-cache-volume
env:
@@ -17,8 +43,47 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
env
GOBIN=/usr/local/go/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.1
apk add go
GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
gocheckout
- name: test
- name: install docker
run: apk add sudo docker
- name: build CI container
run: ops/devcontainer/build.sh
release-test:
needs: build-docker-bootstrap
if: "!failure()"
container:
image: gas
volumes:
- woodpecker-gocache:/go-cache-volume
env:
GOPATH: /go-cache-volume
GOCACHE: /go-cache-volume/build-cache
GOLANGCI_LINT_CACHE: /go-cache-volume/golangci-lint
steps:
- name: checkout
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
go test ./...
env
ls -la /usr/local/bin
gocheckout
- name: test
run: go test -tags fts5 ./...
- name: lint
run: golangci-lint run
- name: lint shell
run: find ops/ -name "*.sh" | xargs shellcheck
- name: Run sqlite_lint integration test
run: ops/sqlite_lint_test.sh
- name: Run 'gas init' and 'gas generate'
run: ops/gas_init_test.sh

92
.golangci.yaml Normal file
View File

@@ -0,0 +1,92 @@
version: "2"
linters:
default: none
enable:
# Defaults
- errcheck
- govet
- ineffassign
- staticcheck
- unused
# Extras
- depguard
- errorlint
- godox
- lll
- nolintlint
- sqlclosecheck
- whitespace
- wrapcheck
settings:
depguard:
rules:
main:
deny:
- pkg: io/ioutil
desc: replace with the matching functions from `io` or `os` packages
- pkg: github.com/pkg/errors
desc: Should be replaced by standard lib errors package
errcheck:
# report about not checking of errors in type assertions: `a := b.(MyStruct)`;
# default is false: such cases aren't reported by default.
check-type-assertions: true
errorlint:
errorf: true # Ensure Errorf only uses %w (not %v or %s etc) for errors
asserts: true # Require errors.As instead of type-asserting
comparison: true # Require errors.Is instead of equality-checking
godox:
# report any comments starting with keywords, this is useful for TODO or FIXME comments that
# might be left in the code accidentally and should be resolved before merging
keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting
- XXX
govet:
enable-all: true
disable:
- fieldalignment
lll:
line-length: 140
tab-width: 4
nolintlint:
require-explanation: true
require-specific: true
allow-unused: false
staticcheck:
go: "1.24"
checks:
- all
- -ST1000 # Re-enable this once we have docstrings
- -ST1003 # I like snake_case
- -ST1013 # HTTP status codes are shorter and more readable than names
dot-import-whitelist:
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
exclusions:
generated: lax # Don't lint generated files
paths:
formatters:
enable:
- gci
- gofmt
settings:
gci:
sections:
- standard
- default
- localmodule
gofmt:
simplify: true
exclusions:
generated: lax
paths:
run:
build-tags:
- integration
issues:
max-same-issues: 0
max-issues-per-linter: 0
uniq-by-line: false

29
cmd/main.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
const (
GREEN = "\033[0;32m"
RED = "\033[0;31m"
RESET = "\033[0m"
)
func main() {
root_cmd := &cobra.Command{
Use: "gas",
SilenceErrors: true,
SilenceUsage: true,
}
root_cmd.AddCommand(sqlite_lint)
root_cmd.AddCommand(cmd_init)
root_cmd.AddCommand(generate_model)
if err := root_cmd.Execute(); err != nil {
fmt.Println(RED + err.Error() + RESET)
os.Exit(1)
}
}

View File

@@ -0,0 +1,90 @@
package main
import (
"errors"
"fmt"
"go/ast"
"go/printer"
"go/token"
"os"
"github.com/spf13/cobra"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen/modelgenerate"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
var ErrNoSuchTable = errors.New("no such table")
var generate_model = &cobra.Command{
Use: "generate <model_name>",
Short: "Generate a model type",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := Must(cmd.Flags().GetString("schema"))
modname := Must(cmd.Flags().GetString("modname"))
schema_sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading path %s: %w", path, err)
}
db := schema.InitDB(string(schema_sql))
tables := schema.SchemaFromDB(db).Tables
table, isOk := tables[args[0]]
if !isOk {
return ErrNoSuchTable
}
fset := token.NewFileSet()
if Must(cmd.Flags().GetBool("test")) {
file2 := modelgenerate.GenerateModelTestAST(table, modname)
PanicIf(printer.Fprint(os.Stdout, fset, file2))
} else {
file := &ast.File{
Name: ast.NewIdent("db"), // TODO: parameterize
Decls: []ast.Decl{
&ast.GenDecl{
Tok: token.IMPORT,
Specs: []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"database/sql"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"errors"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
&ast.ImportSpec{
Name: ast.NewIdent("."),
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
},
&ast.ImportSpec{
Name: ast.NewIdent("."),
Path: &ast.BasicLit{
Kind: token.STRING,
Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`,
},
},
},
},
modelgenerate.GenerateIDType(table),
modelgenerate.GenerateModelAST(table),
modelgenerate.GenerateSQLFieldsConst(table),
modelgenerate.GenerateSaveItemFunc(table),
modelgenerate.GenerateDeleteItemFunc(table),
modelgenerate.GenerateGetItemByIDFunc(table),
modelgenerate.GenerateGetAllItemsFunc(table),
},
}
PanicIf(printer.Fprint(os.Stdout, fset, file))
}
return nil
},
}
func init() {
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
generate_model.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
generate_model.Flags().Bool("test", false, "Generate test file instead of regular file")
}

70
cmd/subcmd_init.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/codegen"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
)
var cmd_init = &cobra.Command{
Use: "init [path]",
Short: "Initialize a new project",
Long: "Initialize a new Gas Stack project at the given path. If no path is given, defaults to current directory.",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
var target string
if len(args) != 0 {
target = args[0]
PanicIf(os.MkdirAll(target, 0o755))
PanicIf(os.Chdir(target))
} else {
// Default to current directory (".")
target = Must(os.Getwd())
}
// Get all the config values
get_val := func(prompt string, val *string) {
fmt.Printf("%s (%q): ", prompt, *val)
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
input := scanner.Text()
if input != "" {
*val = input
}
}
pkg_opts := codegen.PkgOpts{
ModuleName: Must(cmd.Flags().GetString("module")),
DBFilename: Must(cmd.Flags().GetString("db")),
BinaryName: Must(cmd.Flags().GetString("binary")),
}
if pkg_opts.ModuleName == "" {
pkg_opts.ModuleName = filepath.Base(target)
get_val("module name", &pkg_opts.ModuleName)
}
if pkg_opts.DBFilename == "" {
pkg_opts.DBFilename = pkg_opts.ModuleName + ".db"
get_val("db name", &pkg_opts.DBFilename)
}
if pkg_opts.BinaryName == "" {
pkg_opts.BinaryName = pkg_opts.ModuleName
get_val("binary name", &pkg_opts.BinaryName)
}
// Run project initialization
codegen.InitPkg(pkg_opts)
},
}
func init() {
cmd_init.Flags().String("module", "", "Module name")
cmd_init.Flags().String("db", "", "Database filename")
cmd_init.Flags().String("binary", "", "Binary name")
}

79
cmd/subcmd_sqlite_lint.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
)
var sqlite_lint = &cobra.Command{
Use: "sqlite_lint <path/to/schema.sql>",
Short: "Lint a SQLite schema",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("required: filepath as first argument")
}
return nil
},
Run: func(rootCmd *cobra.Command, args []string) {
filename := args[0]
fmt.Printf("-----------------\nLinting %s\n", filename)
schema, err := schema.SchemaFromSQLFile(args[0])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
is_failure := false
// Execute each check against the database
for _, check := range lint.Checks {
// Checks can be disabled via Github config / environment variables
if !is_check_enabled(check) {
continue
}
results := check.Execute(schema)
// If there are results, print them as lint errors
if len(results) > 0 {
is_failure = true
fmt.Printf(RED+"Check '%s' failed:\n"+RESET, check.Name)
for _, result := range results {
fmt.Printf(RED+"- %s: %s.%s\n"+RESET, result.ErrorMsg, result.TableName, result.ColumnName)
}
fmt.Printf(RED+"Explanation: %s\n\n"+RESET, check.Explanation)
}
}
if is_failure {
fmt.Println(RED + "Errors found" + RESET)
os.Exit(1)
}
fmt.Println(GREEN + "Success" + RESET)
},
}
// github_actions_input_env_var converts an input name to the corresponding
// environment variable name used by GitHub Actions.
func github_actions_input_env_var(name string) string {
// GitHub normalizes both hyphens and underscores to underscores, then uppercases the name
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(name)
return "INPUT_" + strings.ToUpper(normalized)
}
// Setting the environment variable INPUT_REQUIRE_NOT_NULL="false" disables the "require_not_null" check
func is_check_enabled(c lint.Check) bool {
val, is_set := os.LookupEnv(github_actions_input_env_var(c.Name))
if !is_set {
// Enable all checks by default
return true
}
// Anything except "false" is true
return val != "false"
}

10
doc/3-letter-names Normal file
View File

@@ -0,0 +1,10 @@
## Alternatives to `ops`:
mng
dev
run
env
adm
kit
wrk
hlp

17
doc/TODO.txt Normal file
View File

@@ -0,0 +1,17 @@
TODO: auto-timestamps
- SaveXyz should set created_at and updated_at; shouldn't touch is_deleted or deleted_at
- if soft delete is enabled, DeleteXyz should do update (not delete) and set is_deleted and deleted_at
- ...and DeleteXyz should have pointer receiver for soft-delete
- SaveXyz shouldn't set created_at in the do-update branch
- GetXyzByID should include `ErrItemIsDeleted` if item is soft-deleted
TODO: modified-timestamps
- set updated_at and created_at in SaveXYZ
- soft delete option
TODO: generator-foreign-keys
- add auto-foreign-key checking blocks to SaveXyz
TODO: migration-structs
- Right now, migrations are strings. Could be a struct with "name", "up" and "down" fields
- Adding a "down" operation enables handling newer DB versions with "down instead of error-out" for development (perhaps a flag)

157
doc/gas-stack.md Normal file
View File

@@ -0,0 +1,157 @@
# GAS stack
## Concepts
- explict `schema.sql`
- solid-state applications
- no Docker
- https://git.tnd.gg/tnd/remichat/pulls/4#issuecomment-1218
- no environment split ("prod" vs "dev")
- environment distinctions are provided by scripts that call the application, not by the application
- try to avoid config files-- use shell scripts that pass flags instead
- put all the config variable values for a certain environment in that environment's runner context:
- values that are specific to Docker setups should go in docker-compose
- values that are for non-docker dev, maybe in .env or a runner shell script, or something like this
- assume that config values are finalized (i.e., don't need to be checked configVal == "" ? "default" : configVal) as high in the stack as possible (and also ensure this is the case). Ideally, vite.config.ts and onward already shouldn't need to do any val || "default" checking, and can simply accept env.PORT_NUMBER or env.IS_LOGGING_ENABLED as-is (or at most, parsing string->int, etc).
- https://git.tnd.gg/tnd/remichat/pulls/38#issuecomment-1645
- no frontend-backend separation (CORS)
- minimal config
- avoid cascading configs and multiple layers of defaults (e.g., `{ XYZ: "prefix" + (upperlayer.XYZ || "DefaultValue"), ... }`)
- subcommands executables
- scriptable applications: anything that the application can do, can be done via command line (i.e., avoid web-only operations)
- config is passed as command-line args
- avoid config env-vars (magical)
- shell scripts as glue code
- sample data
- https://git.tnd.gg/tnd/remichat/pulls/65#issuecomment-2035
- `ops` directory
### Borrowed from Ruby On Rails
- Convention Over Configuration
- scaffolding (code generators)
- database migration system
- focus on testing
- ORM-like affordances (but not actually using an ORM)
## Vendoring vs Package Management vs In-Sourcing
Dependencies are technical debt. See [Loris Cro's talk about "How To Write Better Software with Zig"](https://www.youtube.com/watch?v=AEybWzeAkho).
## Scaffolding
Scaffolding is not boilerplate code, generated code, or library code. It's *starter code* which is *intended to be modified* as needed.
Library code is provided as a pre-made, off-the-shelf solution. If your problem is exactly the one the library is intended to solve, and the library does a good job, you should use it. Lots of big stuff is like this; nobody implements their own HTTP server or SQL engine as part of an application, because the domain is big, stable and standardized. It makes perfect sense to use libraries for this.
Boilerplate code and generated code (the latter being a common solution to the former) are usually indicators of bad abstractions. If there's truly something that needs to be done exactly the same way by rote, every time, then there should be a reusable library for it-- or maybe you're even using the wrong programming language.
Scaffolding isn't either of those, because scaffolding is intended to be *changed*. It's just a starting point as you flesh out your ideas.
Consider [this parable](https://rcrowley.org/2022/rails-django-parable.html) comparing Rails and Django on their initial setup and tutorial. The analogy isn't perfect, but his claim is basically that the original Rails official tutorial left you with a tiny app with almost on code and a huge amount of functionality; but since all the functionality was invisible "magic" provided by Rails, as soon as you want something custom, you're nearly starting from scratch'. By comparison, the Django tutorial produces a large amount of code which makes the abstractions explicit. The author refers to all those extra lines of code as "footholds", from which you can start working.
Scaffolding *begins* as generic boilerplate, but evolves as your application logic becomes more custom and requirements change. One piece of scaffolded code might never change, because the scaffolding was good enough; another piece might be tweaked over time, as you add more to it (or remove parts you don't need); and another piece might change so much that no traces of the original scaffolding existed. One app could contain all three of these.
Scaffolding is intended to make "in-sourcing" your code easier, by getting you to something bare-bones-but-working faster.
## Dynamic vs Static linking
TODO: write about this and why it matters for the GAS stack
## SQLite and ROWID
Tables must be EITHER:
- `rowid integer primary key` as first column declaration; OR
- `without rowid`, i.e., a clustered index
Other primary key and rowid settings are supported by SQLite, but NOT by Gas Stack:
- implicit `rowid`
- alternate `integer primary key` column, i.e., rowid alias
- non-rowid `primary key` that isn't a clustered index (note that SQLite doesn't actually support this, it just pretends to; the declared `primary key` in this case is just a regular unique index)
This is enforced by the Gas Stack schema linter, and cannot be configured. Other Gas Stack schema tools assume one of these arrangements, and will not work right if it's not followed.
## What not to do
- "scripts" folder
- "bin" with shell scripts in it
- using HTTP PUT or PATCH
## App structure
- pkg/
- db/
- schema.sql
- db_connect.go (migrations, versions and associated funcs, sql_schema, DBCreate, DBConnect)
- db_connect_test.go
- test_utils/
- db_setup.go
- cmd/
- main.go (parameterize DB_FILENAME)
- doc/
- sample_data/
- mount.sh
- seed.sql
- data/
- .github/
- workflows/
- build.yml
- .gitignore (sample_data/data)
- .golangci.yaml
- README.md
- ARCHITECTURE.md
gas init:
- git init
- go mod init
Params:
- omit sqlite
- omit github workflow
- database filename
- go module name
- include HTTP? (pkg/web, templ)
- data directory or just db file
gas generate_boilerplate [db-table-name]:
- struct
- Save function
- Get[Type]ByID function
gas generate-web
- install templ
- echo "*_templ.go" >> .gitignore
- pkg/web
- server.go
- middlewares.go
- web/
- static/
- vendor/
- styles.css
- tpl/
- server.go
- middlewares.go
- static.go
gas generate-subcommand
Considerations:
- godoc
# Methodologies
- Timestamp type: store dates and times as int64 (unix millis)
- code tags: TODO, XXX, WTF, DUPE tags
- `go test -tags integration` for integration testing (slow tests)
- cobra commands
- sqlx
- :memory: databases for testing
- go-chi router
- No docker-compose

7
doc/inspirations.txt Normal file
View File

@@ -0,0 +1,7 @@
- Urbit
- BCHS stack
- Ruby on Rails
- Hasen Judi's "Data Storage and Retrieval From First Principles": https://hasen.substack.com/p/data-storage-and-retrieval
- Matklad's "Basic Things": https://matklad.github.io/2024/03/22/basic-things.html
- Zig, Andrew Kelley, Loris Cro
- Max Tagher's "8 Lints for your Postgres Schema": https://mercury.com/blog/lints-for-postgres-schema

1
doc/links.txt Normal file
View File

@@ -0,0 +1 @@
https://astexplorer.net/

View File

@@ -0,0 +1,41 @@
See: https://guides.rubyonrails.org/v3.2/getting_started.html#getting-up-and-running-quickly-with-scaffolding
# DB
db/migrate/20100207214725_create_posts.rb:
- Migration to create the posts table in your database (your name will include a different timestamp)
app/models/post.rb:
- The Post model
test/unit/post_test.rb:
- Unit testing harness for the posts model
test/fixtures/posts.yml:
- Sample posts for use in testing
# Web
config/routes.rb:
- Edited to include routing information for posts
app/controllers/posts_controller.rb:
- The Posts controller
app/views/posts/index.html.erb:
- A view to display an index of all posts
app/views/posts/edit.html.erb:
- A view to edit an existing post
app/views/posts/show.html.erb:
- A view to display a single post
app/views/posts/new.html.erb:
- A view to create a new post
app/views/posts/_form.html.erb:
- A partial to control the overall look and feel of the form used in edit and new views
test/functional/posts_controller_test.rb:
- Functional testing harness for the posts controller
app/helpers/posts_helper.rb:
- Helper functions to be used from the post views
test/unit/helpers/posts_helper_test.rb:
- Unit testing harness for the posts helper
app/assets/javascripts/posts.js.coffee:
- CoffeeScript for the posts controller
app/assets/stylesheets/posts.css.scss:
- Cascading style sheet for the posts controller
app/assets/stylesheets/scaffolds.css.scss:
- Cascading style sheet to make the scaffolded views look better

View File

@@ -0,0 +1,88 @@
# SQLite Schema Rules and Linter
The `sqlite_lint` subcommand enforces some rules that the GAS stack considers best-practices.
All checks are enabled by default. Disabling checks isn't recommended; many GAS stack methodologies assume your schema is designed in accordance with these rules, and will be less effective if you don't follow them.
Currently the only way to disable them is setting an environment variable with the check name in capitals prefixed with `INPUT_`, e.g., `INPUT_REQUIRE_NOT_NULL=false` disables the `require_not_null` check.
```bash
INPUT_REQUIRE_NOT_NULL=false gas sqlite_lint <path/to/schema.sql> # `require_not_null` check will be skipped
```
## Running the linter
```bash
gas sqlite_lint <path/to/schema.sql>
```
## Available Checks
This is a list of currently available checks.
### `require_not_null`
Enforce that all columns should be marked as `not null`, unless they are foreign keys.
**Explanation**:
- Nulls are a common source of unexpected bugs, because they're usually an invalid state but often get created by mistake (e.g., you forgot to set a value). Explicitly disabling nulls prevents such mistakes.
- If the "natural zero value" is a valid value in your application and you explicitly need to distinguish it from "missing data", use an `has_xyz` or `is_xyz_valid` flag of some kind, rather than a nullable field.
- This is usually unnecessary, because the natural zero-values `0` and `""` (empty string) are usually sufficient to indicate "no value". This is called a "sentinel value", or "in-band null value", because you don't need a special data type (null) to declare absence of data.
- Foreign keys are exempt in this check, because `null` is a special value the integrity checker uses to say "this row has no related item".
### `require_strict`
Enforce that all tables should be marked as `strict`.
**Explanation**:
- By default, SQLite tables are very loose with what values they accept, and don't enforce any type checking. "Strict" disables this "looseness", and enforces that inserted values match the stated type of the column.
- "Strict" tables also limit to a small number of column types: `int`, `integer`, `real`, `text`, `blob` or `any`.
- To represent dates / times, use Unix epoch times in milliseconds, and convert to formatted dates (and timezones) only when displaying the value to a user. This is the most portable and least bug-prone method to handle dates.
See more about "strict" tables in SQLite's documentation: <https://sqlite.org/stricttables.html>
### `forbid_int_type`
Enforce that all columns should use `integer` type instead of `int`.
**Explanation**:
- This is an extension of "strict" tables, which allow two redundant integer types, `integer` and `int`. This check standardizes the types further, permitting only `integer`.
### `require_explicit_primary_key`
Enforce that all tables must have an explicitly declared primary key.
**Explanation**:
- All tables need to have a primary key for storage reasons, so if you don't declare one, SQLite will auto-generate a hidden "rowid" column. Making it explicit (rowid or otherwise) improves schema readability.
See more about the special behavior of `rowid` in SQLite's documentation: <https://sqlite.org/lang_createtable.html#rowid>
### `require_explicit_rowid`
Enforce that any table that's not declared `without rowid` has an explicit `rowid integer primary key` column.
**Explanation**:
- In SQLite, all tables implicitly have a `rowid` column unless they are declared `without rowid`. Making it explicit improves schema readability.
See more about the `without rowid` modifier in SQLite's documentation: <https://sqlite.org/withoutrowid.html>
### `forbid_rowid_on_without_rowid_table`
Enforce that `without rowid` tables don't have a rowid column.
**Explanation**:
- This is pretty self explanatory. You can technically give a `without rowid` table a rowid column. But don't.
### `require_indexes_for_foreign_keys`
Enforce that columns referenced by foreign keys must have indexes.
**Explanation**:
- Foreign keys are usually used for `join`s. Joining on un-indexed columns is very slow. Ensuring that all foreign-key-referenced columns have indexes will greatly improve the performance of database operations.

63
doc/using-devcontainer.md Normal file
View File

@@ -0,0 +1,63 @@
# Dev containers
A container is a great development environment. However, they tend to underperform because:
1. it's easy to fall into the Docker Compose trap
1. people don't use Alpine
Using a dev container has multiple benefits:
- "infrastructure as code", a.k.a. . Your codebase itself (in the `ops/devcontainer` dir) defines explicitly defines all the tools and dependencies that you use, with .
- reproducibility: a few simple commands to create a clean working setup anywhere Docker is supported (i.e., anywhere)
- isolation: you have clean setup and teardown. You can install a bunch of crap to try it out, and you don't have to remember what it was so you can purge it afterward. Just delete the container.
## Quick start
Build the container:
```bash
ops/devcontainer/build.sh
```
Run the container:
```bash
ops/devcontainer/start.sh
```
## Concepts
A dev container is meant to be short-lived, constantly thrown away and recreated as needed. This explicitly divides the filesystem into "keep" (the source tree and any useful artifacts / caches) and "throw away" (everything else). Frequently regenerating the container ensures that "your environment" never deviates too far from the Infrastructure As Code in your repo; it forces you to add any new tools to the Docker image build process. In service of this, using `docker run --rm [...]` is always recommended.
Contrary to conventional container ideology, it is not necessary to keep your images tiny and minimize container layers at all costs. For example, conventional container ideology frequently suggests constructs like `RUN cmd1 && cmd2 && cmd3` rather than doing each `cmd` in its own `RUN` layer, in order to reduce the amount of layers generated from 3 to 1. These practices are optimized for massive horizontal deployments, where you have a gazillion containers and images, and resource usage is a big problem. Obviously, the GAS stack is the complete opposite; you want very few containers, ideally just 1 at most. So having *more* layers is actually better, because it speeds up rebuild times by avoiding very heavy, frequently rebuilt layers. It also makes the Dockerfile much easier to read.
## Methodologies
There's a few useful techniques and strategies when using dev containers:
- user management
- volumes for code
- volumes for caching
- openrc services
- `--net host`
### User management
To make working in a dev container seamless, create a user on it that matches your host machine user (UID and GID).
If you don't do this, Git will complain about conflicting ownership, and any tools or tests that create files will create them as "root", which then have to be constantly `chown`'d on the host.
To make this work, it's necessary to have a `build.sh` script which passes the current user's UID as a build arg to the Docker build step.
### Volumes
Anything not in a volume (or built into the image) will be lost on container restart. I like to mount the codebase on `/code`.
For compiled languages (or anything that needs to "build" the project, e.g., linters), mounting build cache directories can also be useful.
### OpenRC
OpenRC is much simpler than systemd. If you want to run background processes, or network services, making the root process OpenRC and writing an openrc service script is the best effort-to-value ratio. ChatGPT can help you write openrc service scripts.
### `--net host`
Because this is a dev container, it's meant to make your life easier, not get you tangled up in security best-practices and so forth. One of the biggest annoyances of using containers is having to do port mapping, which leads to an explosion of config.
Using `docker run --net host [...]` makes the container use the host's networking, instead of creating a virtual network that you have to explicitly map ports back and forth between.

3
go.mod
View File

@@ -6,11 +6,14 @@ require (
github.com/jinzhu/inflection v1.0.0
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

8
go.sum
View File

@@ -1,9 +1,12 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@@ -15,6 +18,11 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

30
ops/README.md Normal file
View File

@@ -0,0 +1,30 @@
# ops/
The ops/ directory is intended as a semi-organized dumping ground for various types of programs and scripts that exist around the application, but are not actually part of it. (E.g., they will not get deployed, but they might be used *to* deploy the app.)
It's intended to be neither totally chaotic, nor totally pristine.
Stuff in this directory is expected to begin life as crappy Shell scripts, and, depending on needs, possibly evolve into e.g. Python or Go programs.
It can be used for
- development tools and helpers
- build and CI scripts
- unwieldy integration tests
- environment setup / teardown
- recurring complex operations that are part of the dev flow (e.g., testing out the schema of an external API)
- data visualizations
## Purpose
This is an experimental pattern that I'm trying out. It's basically like "scripts/", but it follows the "3-letter top level directory" convention.
It's also less constraining than "scripts", which would appear to disallow things like full Go programs, which I explicitly want to allow, and would actually consider desirable.
Shell scripts are fragile and finicky by nature. For example, any kind of error handling is notoriously difficult in Shell. Yet, error states are *extremely common* in anything that you're using Shell for:
- network failures
- some file doesn't exist / already exists
- permission denied
- some string variable is empty
Therefore, it's natural that important tasks should eventually be implemented using a proper programming language. Beyond a certain level of effort, "Just write it in Go" is actually easier. Or at least Python.

3
ops/compile.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
go build -tags fts5 -o gas ./cmd

View File

@@ -0,0 +1,25 @@
# A dev container.
FROM alpine:3.22
RUN apk add build-base git go sqlite vim shellcheck curl jq sudo bash docker
# Busybox `less` doesn't appear to support colors (makes git diff lose color)
RUN apk add less
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v2.0.2
RUN GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
# Create a user in the container with the same UID as on the host machine, to avoid ownership conflicts.
# The user gets sudo of course.
#
# If the host user is `root` (uid = 0), we skip creating a new user, because it will fail otherwise.
ARG USERNAME
ARG UID
ARG GID
RUN if [ "${UID}" -ne 0 ]; then \
addgroup -g "${GID}" "${USERNAME}" && \
adduser -D -u "${UID}" -G "${USERNAME}" "${USERNAME}" && \
echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/"${USERNAME}"; \
fi
USER ${USERNAME}

9
ops/devcontainer/build.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
SCRIPT_PATH=$(cd "$(dirname "$0")" && pwd)
sudo docker build \
--build-arg USERNAME="$(whoami)" \
--build-arg UID="$(id -u)" \
--build-arg GID="$(id -g)" \
-t gas "$SCRIPT_PATH"

12
ops/devcontainer/start.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
sudo docker run --rm -it \
-v "$(pwd)":/code \
-v "$(go env GOCACHE):/gocache-vol" \
-e GOCACHE=/gocache-vol \
-v "$(go env GOMODCACHE):/gocache-vol/mod-cache" \
-e GOMODCACHE=/gocache-vol/mod-cache \
-e GOLANGCI_LINT_CACHE=/gocache-vol/lint-cache \
--workdir /code \
--net host \
gas

52
ops/gas_init_test.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# ------------------
# This is a test script that initializes a new project.
# ------------------
set -e
set -x
PS4='+(${BASH_SOURCE}:${LINENO}): '
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Compile `gas`
gas="/tmp/gas"
ops/compile.sh
mv gas $gas
test_project="/memory/test_gasproj"
if [[ -e $test_project ]]; then
rm -r "$test_project"
fi
$gas init "$test_project" <<EOF
mymodule
mydb.db
prog
EOF
cd $test_project
# Create a new table in the schema
cat >> pkg/db/schema.sql <<EOF
create table items (
rowid integer primary key,
description text not null default ''
) strict;
EOF
# Generate an item model and test file
$gas generate items > pkg/db/item.go
$gas generate items --test > pkg/db/item_test.go
go mod tidy
# Run the tests
go test ./...
# Run sqlite_lint
$gas sqlite_lint pkg/db/schema.sql
# Notify success in green
echo -e "\033[32mAll tests passed. Finished successfully.\033[0m"

48
ops/sqlite_lint_test.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# ------------------
# This is a test script that checks that `sqlite_lint` subcommand works as expected.
# ------------------
set -e
set -x
PS4='+(${BASH_SOURCE}:${LINENO}): '
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Compile `gas`
gas="/tmp/gas"
ops/compile.sh
mv gas $gas
test_schema_dir="pkg/schema/lint/test_schemas"
# Check all failure cases
for test_schema in "$test_schema_dir"/failure-*; do
output=$($gas sqlite_lint "$test_schema") && {
echo "Expected test to fail"
exit 9
}
# Should print the name of the file being linted
if ! echo "$output" | grep -Fxq "Linting $test_schema"; then
echo "Expected 'Linting $test_schema' in output"
exit 10
fi
done
# Check success case
success_schema="$test_schema_dir/success.sql"
output=$($gas sqlite_lint $success_schema) || {
echo "Expected test to succeed"
exit 9
}
# Should print the name of the file being linted
if ! echo "$output" | grep -Fxq "Linting $success_schema"; then
echo "Expected 'Linting $success_schema' in output"
exit 10
fi
# Notify success in green
echo -e "\033[32mAll tests passed. Finished successfully.\033[0m"

View File

@@ -0,0 +1,376 @@
//nolint:lll // This file has lots of long lines lol
package modelgenerate
import (
"fmt"
"go/ast"
"go/token"
"strings"
"github.com/jinzhu/inflection"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
func GenerateIDType(table schema.Table) *ast.GenDecl {
// e.g., `type FoodID int`
return &ast.GenDecl{
Tok: token.TYPE,
Specs: []ast.Spec{&ast.TypeSpec{Name: ast.NewIdent(table.TypeIDName), Type: ast.NewIdent("int")}},
}
}
// GenerateModelAST produces an AST for a struct type corresponding to the model.
// TODO: generate the right field types here based on column types.
func GenerateModelAST(table schema.Table) *ast.GenDecl {
// Fields for the struct
fields := []*ast.Field{}
// Other fields (just strings for now)
for _, col := range table.Columns {
switch col.Name {
case "rowid":
fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent("ID")},
Type: ast.NewIdent(table.TypeIDName),
Tag: &ast.BasicLit{Kind: token.STRING, Value: "`db:\"rowid\" json:\"id\"`"},
})
default:
if col.IsForeignKey && strings.HasSuffix(col.Name, "_id") {
fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(strings.TrimSuffix(col.Name, "_id")) + "ID")},
Type: ast.NewIdent(schema.TypenameFromTablename(col.ForeignKeyTargetTable) + "ID"),
Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)},
})
} else {
typeName := "string"
switch col.Type {
case "integer", "int":
if strings.HasPrefix(col.Name, "is_") || strings.HasPrefix(col.Name, "has_") {
typeName = "bool"
} else if strings.HasSuffix(col.Name, "_at") {
typeName = "Timestamp"
} else {
typeName = "int64"
}
case "text":
typeName = "string"
case "real":
typeName = "float32"
case "blob":
typeName = "[]byte"
default:
panic("Unrecognized sqlite column type: " + col.Type)
}
fields = append(fields, &ast.Field{
Names: []*ast.Ident{ast.NewIdent(textutils.SnakeToCamel(col.Name))},
Type: ast.NewIdent(typeName),
Tag: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("`db:\"%s\" json:\"%s\"`", col.Name, col.Name)},
})
}
}
}
return &ast.GenDecl{
Tok: token.TYPE,
Specs: []ast.Spec{&ast.TypeSpec{
Name: ast.NewIdent(table.GoTypeName),
Type: &ast.StructType{Fields: &ast.FieldList{List: fields}},
}},
}
}
// GenerateSaveItemFunc produces an AST for the SaveXyz() function of the model.
// E.g., a table with `table.TypeName = "foods"` will produce a "SaveFood()" function.
func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
insertCols := make([]string, 0, len(tbl.Columns))
insertVals := make([]string, 0, len(tbl.Columns))
updatePairs := make([]string, 0, len(tbl.Columns))
for _, col := range tbl.Columns {
if col.Name == "rowid" {
continue
}
insertCols = append(insertCols, col.Name)
val := ":" + col.Name
if col.IsNullableForeignKey() {
val = fmt.Sprintf("nullif(%s, 0)", val)
}
insertVals = append(insertVals, val)
updatePairs = append(updatePairs, col.Name+"="+val)
}
insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t", tbl.TableName, strings.Join(insertCols, ", "), strings.Join(insertVals, ", "))
updateStmt := fmt.Sprintf("\n\t\t update %s\n\t\t set %s\n\t\t where rowid = :rowid\n\t\t", tbl.TableName, strings.Join(updatePairs, ",\n\t\t "))
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
Op: token.EQL,
Y: &ast.BasicLit{Kind: token.INT, Value: "0"},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"},
ast.NewIdent(tbl.VarName),
},
}},
}},
},
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent(tbl.TypeIDName),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")},
Args: []ast.Expr{},
}},
}},
}},
},
},
},
Else: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: "`" + updateStmt + "`"}, ast.NewIdent(tbl.VarName)},
}},
}},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: &ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")},
Args: []ast.Expr{},
}},
},
Op: token.NEQ,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ExprStmt{X: &ast.CallExpr{Fun: ast.NewIdent("panic"), Args: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("fmt.Errorf"), Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("\"got %s with ID (%%d), so attempted update, but it doesn't exist\"", strings.ToLower(tbl.GoTypeName))}, &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}}}}}}}},
},
},
},
},
},
}
funcDecl := &ast.FuncDecl{
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}},
Name: ast.NewIdent("Save" + tbl.GoTypeName),
Type: &ast.FuncType{
Params: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: &ast.StarExpr{X: ast.NewIdent(tbl.GoTypeName)}}}},
Results: nil,
},
Body: funcBody,
}
return funcDecl
}
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
funcName := "Get" + tbl.GoTypeName + "ByID"
recv := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
arg := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("id")}, Type: ast.NewIdent(tbl.TypeIDName)}}}
result := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: ast.NewIdent(tbl.GoTypeName)}, {Names: []*ast.Ident{ast.NewIdent("err")}, Type: ast.NewIdent("error")}}}
// Use the xyzSQLFields constant in the select query
selectExpr := &ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`\n\t select `"},
Op: token.ADD,
Y: SQLFieldsConstIdent(tbl),
},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: "`\n\t from " + tbl.TableName + "\n\t where rowid = ?\n\t`"},
}
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("err")},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("Get")}, Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr, ast.NewIdent("id")}}},
},
&ast.IfStmt{
Cond: &ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")}, Args: []ast.Expr{ast.NewIdent("err"), &ast.SelectorExpr{X: ast.NewIdent("sql"), Sel: ast.NewIdent("ErrNoRows")}}},
Body: &ast.BlockStmt{List: []ast.Stmt{&ast.ReturnStmt{Results: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent(tbl.GoTypeName)}, ast.NewIdent("ErrNotInDB")}}}},
},
&ast.ReturnStmt{},
},
}
funcDecl := &ast.FuncDecl{
Recv: recv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody,
}
return funcDecl
}
// GenerateGetAllItemsFunc produces an AST for the `GetAllXyzs()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetAllFoods()" function.
func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
funcName := "GetAll" + inflection.Plural(tbl.GoTypeName)
recv := &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")},
}}
result := &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("ret")}, Type: &ast.ArrayType{Elt: ast.NewIdent(tbl.GoTypeName)}},
}}
selectCall := &ast.CallExpr{
Fun: ast.NewIdent("PanicIf"),
Args: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: ast.NewIdent("db.DB"),
Sel: ast.NewIdent("Select"),
},
Args: []ast.Expr{
&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")},
&ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.BasicLit{Kind: token.STRING, Value: "`SELECT `"},
Op: token.ADD,
Y: SQLFieldsConstIdent(tbl),
},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.STRING, Value: "` FROM " + tbl.TableName + "`"},
},
},
},
},
}
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
&ast.ExprStmt{X: selectCall},
&ast.ReturnStmt{},
},
}
return &ast.FuncDecl{
Recv: recv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{
Params: &ast.FieldList{},
Results: result,
},
Body: funcBody,
}
}
// GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function.
func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
funcName := "Delete" + tbl.GoTypeName
recv := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
arg := &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, Type: ast.NewIdent(tbl.GoTypeName)}}}
funcBody := &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("db.DB"), Sel: ast.NewIdent("Exec")},
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
},
}},
}},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: &ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("RowsAffected")}, Args: []ast.Expr{}},
},
},
Op: token.NEQ,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
Body: &ast.BlockStmt{List: []ast.Stmt{
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("panic"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("fmt.Errorf"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("\"tried to delete %s with ID (%%d) but it doesn't exist\"", strings.ToLower(tbl.GoTypeName))},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")},
},
}},
}},
}},
},
},
}
funcDecl := &ast.FuncDecl{
Recv: recv,
Name: ast.NewIdent(funcName),
Type: &ast.FuncType{Params: arg, Results: nil},
Body: funcBody,
}
return funcDecl
}
// GenerateSQLFieldsConst produces an AST for the `const xyzSQLFields = ...` string.
func GenerateSQLFieldsConst(tbl schema.Table) *ast.GenDecl {
columns := make([]string, 0, len(tbl.Columns))
for _, col := range tbl.Columns {
columns = append(columns, col.Name)
}
// Join with comma and space
value := "`" + strings.Join(columns, ", ") + "`"
return &ast.GenDecl{
Tok: token.CONST,
Specs: []ast.Spec{
&ast.ValueSpec{
Names: []*ast.Ident{SQLFieldsConstIdent(tbl)},
Values: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: value}},
},
},
}
}
// ---------------
// Helpers
// ---------------
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
return ast.NewIdent(strings.ToLower(tbl.GoTypeName) + "SQLFields")
}

View File

@@ -0,0 +1,277 @@
package modelgenerate
import (
"fmt"
"go/ast"
"go/token"
"github.com/jinzhu/inflection"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
// GenerateModelTestAST produces an AST for a starter test file for a given model.
func GenerateModelTestAST(tbl schema.Table, gomodName string) *ast.File {
packageName := "db"
testpackageName := packageName + "_test"
testDBDecl := &ast.GenDecl{
Tok: token.VAR,
Specs: []ast.Spec{
&ast.ValueSpec{
Names: []*ast.Ident{ast.NewIdent("TestDB")},
Type: &ast.StarExpr{X: ast.NewIdent("DB")},
},
},
}
initFuncDecl := &ast.FuncDecl{
Name: ast.NewIdent("init"),
Type: &ast.FuncType{Params: &ast.FieldList{}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("TestDB")},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("MakeDB"),
Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: `"tmp"`}},
}},
},
},
},
}
makeDBHelperDecl := &ast.FuncDecl{
Name: ast.NewIdent("MakeDB"),
Type: &ast.FuncType{
Params: &ast.FieldList{
List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent("dbName")},
Type: ast.NewIdent("string"),
}},
},
Results: &ast.FieldList{
List: []*ast.Field{{Type: &ast.StarExpr{X: ast.NewIdent("DB")}}},
},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
// db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName)))
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("db")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Create"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("fmt.Sprintf"),
Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"file:%s?mode=memory&cache=shared"`},
ast.NewIdent("dbName"),
},
}},
}},
}},
},
// return db
&ast.ReturnStmt{
Results: []ast.Expr{ast.NewIdent("db")},
},
},
},
}
testObj := ast.NewIdent("item")
testObj2 := ast.NewIdent("item2")
fieldName := ast.NewIdent("Description")
description1 := `"an item"`
description2 := `"a big item"`
testCreateUpdateDelete := &ast.FuncDecl{
Name: ast.NewIdent("TestCreateUpdateDelete" + tbl.GoTypeName),
Type: &ast.FuncType{
Params: &ast.FieldList{
List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent("t")},
Type: ast.NewIdent("*testing.T"),
}},
},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
// item := Item{Description: "an item"}
&ast.AssignStmt{
Lhs: []ast.Expr{testObj},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CompositeLit{
Type: ast.NewIdent(tbl.GoTypeName),
Elts: []ast.Expr{
&ast.KeyValueExpr{
Key: fieldName,
Value: &ast.BasicLit{Kind: token.STRING, Value: description1},
},
},
}},
},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Save" + tbl.GoTypeName),
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// require.NotZero(t, item.ID)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("require.NotZero"),
Args: []ast.Expr{ast.NewIdent("t"), &ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
// item2 := Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
}},
},
// assert.Equal(t, "an item", item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.Equal"),
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.BasicLit{Kind: token.STRING, Value: description1},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
// item.Description = "a big item"
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: fieldName}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: description2}},
},
// TestDB.SaveItem(&item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Save" + tbl.GoTypeName),
Args: []ast.Expr{&ast.UnaryExpr{Op: token.AND, X: testObj}},
}},
// item2 = Must(TestDB.GetItemByID(item.ID))
&ast.AssignStmt{
Lhs: []ast.Expr{testObj2},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("Must"),
Args: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
}},
},
// assert.Equal(t, item.Description, item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.Equal"),
Args: []ast.Expr{
ast.NewIdent("t"),
&ast.SelectorExpr{X: testObj, Sel: fieldName},
&ast.SelectorExpr{X: testObj2, Sel: fieldName},
},
}},
// TestDB.DeleteItem(item)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("TestDB.Delete" + tbl.GoTypeName),
Args: []ast.Expr{testObj},
}},
// _, err := TestDB.GetItemByID(item.ID)
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_"), ast.NewIdent("err")},
Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: ast.NewIdent("TestDB.Get" + tbl.GoTypeName + "ByID"),
Args: []ast.Expr{&ast.SelectorExpr{X: testObj, Sel: ast.NewIdent("ID")}},
}},
},
// assert.ErrorIs(t, err, db.ErrNotInDB)
&ast.ExprStmt{X: &ast.CallExpr{
Fun: ast.NewIdent("assert.ErrorIs"),
Args: []ast.Expr{
ast.NewIdent("t"),
ast.NewIdent("err"),
&ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("ErrNotInDB")},
},
}},
},
},
}
testGetAll := &ast.FuncDecl{
Name: ast.NewIdent("TestGetAll" + inflection.Plural(tbl.GoTypeName)),
Type: &ast.FuncType{Params: &ast.FieldList{List: []*ast.Field{
{Names: []*ast.Ident{ast.NewIdent("t")}, Type: &ast.StarExpr{X: ast.NewIdent("testing.T")}},
}}, Results: nil},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("_")},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: ast.NewIdent("TestDB"),
Sel: ast.NewIdent("GetAll" + inflection.Plural(tbl.GoTypeName)),
},
}},
},
},
},
}
return &ast.File{
Name: ast.NewIdent(testpackageName),
Decls: []ast.Decl{
&ast.GenDecl{
Tok: token.IMPORT,
Specs: []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"testing"`}},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
Name: ast.NewIdent("db"),
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"`},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{
Path: &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf(`"%s/pkg/%s"`, gomodName, packageName)},
Name: ast.NewIdent("."),
},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/assert"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"github.com/stretchr/testify/require"`}},
},
},
// var TestDB *DB
testDBDecl,
// func init() { TestDB = MakeDB("tmp") }
initFuncDecl,
// func MakeDB(dbName string) *DB { db := Must(Create(fmt.Sprintf("file:%s?mode=memory&cache=shared", dbName))); return db }
makeDBHelperDecl,
testCreateUpdateDelete,
testGetAll,
},
}
}

51
pkg/codegen/pkg.go Normal file
View File

@@ -0,0 +1,51 @@
package codegen
import (
"embed"
"fmt"
"os"
"os/exec"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
)
//go:embed "tpl"
var tpl embed.FS
type PkgOpts struct {
ModuleName string
DBFilename string
BinaryName string
}
func InitPkg(opts PkgOpts) {
// Run `go mod init`
fmt.Printf("Running... `go mod init %s`\n", opts.ModuleName)
PanicIf(exec.Command("go", "mod", "init", opts.ModuleName).Run())
// Run `git init`, if required
if exec.Command("git", "status").Run() != nil {
// Not in a git repo yet; init one
fmt.Println("Running... `git init`")
PanicIf(exec.Command("git", "init").Run())
}
// Create package structure
PanicIf(os.MkdirAll("pkg/db", 0o755))
PanicIf(os.MkdirAll("cmd", 0o755))
PanicIf(os.MkdirAll("doc", 0o755))
PanicIf(os.MkdirAll("sample_data", 0o755))
PanicIf(os.WriteFile("pkg/db/schema.sql", Must(tpl.ReadFile("tpl/schema.sql")), 0o664))
PanicIf(os.WriteFile("pkg/db/db.go", Must(tpl.ReadFile("tpl/db.go.tpl")), 0o664))
PanicIf(os.WriteFile("sample_data/mount.sh", Must(tpl.ReadFile("tpl/mount.sh")), 0o775))
PanicIf(os.WriteFile("sample_data/reset.sh", Must(tpl.ReadFile("tpl/reset.sh")), 0o775))
// TODO:
// - create `pkg/db/errors.go`
// - create `sample_data/seed.sql`
// - create `sample_data/data/`
// - create `.gitignore`
// - do something with `db_setup.go` (should go in Gas Stack `pkg/db`)
}

38
pkg/codegen/tpl/db.go.tpl Normal file
View File

@@ -0,0 +1,38 @@
package db
import (
_ "embed"
"fmt"
"github.com/jmoiron/sqlx"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"
)
//go:embed schema.sql
var sql_schema string
// Database starts at version 0. First migration brings us to version 1
var MIGRATIONS = []string{}
type DB struct {
DB *sqlx.DB
}
func Create(path string) (*DB, error) {
conf := db.Init(&sql_schema, &MIGRATIONS)
dbHandle, err := conf.Create(path)
if err != nil {
return nil, fmt.Errorf("creating db: %w", err)
}
return &DB{dbHandle}, nil
}
func Connect(path string) (*DB, error) {
conf := db.Init(&sql_schema, &MIGRATIONS)
dbHandle, err := conf.Connect(path)
if err != nil {
return nil, fmt.Errorf("creating db: %w", err)
}
return &DB{dbHandle}, nil
}

3
pkg/codegen/tpl/mount.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
sudo mount -t tmpfs -o size=100M tmpfs sample_data/data

6
pkg/codegen/tpl/reset.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
rm sample_data/data/* || true
go run ./cmd init
sqlite3 sample_data/data/{{ .DBFilename }} < sample_data/seed.sql

View File

@@ -0,0 +1,10 @@
PRAGMA foreign_keys = on;
-- =======
-- DB meta
-- =======
create table db_version (
version integer primary key
) strict, without rowid;
insert into db_version values(0);

View File

@@ -10,9 +10,9 @@ import (
_ "github.com/mattn/go-sqlite3"
)
var (
// Tracks whether the DB connector has been initialized
is_initialized bool
type DBConfig struct {
// // Tracks whether the DB connector has been initialized
// is_initialized bool
// The SQL schema of the database under management
sql_schema *string
@@ -20,11 +20,7 @@ var (
// Database starts at version 0. First migration brings us to version 1
migrations *[]string
version_number uint
)
var (
ErrTargetExists = errors.New("target already exists")
)
}
// Colors for terminal output
const (
@@ -40,64 +36,82 @@ const (
ColorWhite = "\033[97m"
)
func Init(schema *string, migrationsList *[]string) {
sql_schema = schema
migrations = migrationsList
version_number = uint(len(*migrations))
is_initialized = true
func Init(schema *string, migrationsList *[]string) DBConfig {
return DBConfig{
sql_schema: schema,
migrations: migrationsList,
version_number: uint(len(*migrationsList)),
}
}
func Create(path string) (*sqlx.DB, error) {
func (c DBConfig) Create(path string) (*sqlx.DB, error) {
// First check if the path already exists
_, err := os.Stat(path)
if err == nil {
return nil, ErrTargetExists
return nil, ErrDatabaseAlreadyExists
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("path error: %w", err)
}
// Create DB file
fmt.Printf("Creating............. %s\n", path)
db := sqlx.MustOpen("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
db.MustExec(*sql_schema)
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("opening db: %w", err)
}
// Initialize schema
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
return nil, fmt.Errorf("running pragma statements: %w", err)
}
if _, err = db.Exec(*c.sql_schema); err != nil {
return nil, fmt.Errorf("creating schema: %w", err)
}
return db, nil
}
func Connect(path string) (*sqlx.DB, error) {
db := sqlx.MustOpen("sqlite3", fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL", path))
err := CheckAndUpdateVersion(db)
func (c DBConfig) Connect(path string) (*sqlx.DB, error) {
db, err := sqlx.Open("sqlite3", path)
if err != nil {
return nil, fmt.Errorf("opening db: %w", err)
}
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
return nil, fmt.Errorf("running pragma statements: %w", err)
}
err = c.CheckAndUpdateVersion(db)
return db, err
}
func CheckAndUpdateVersion(db *sqlx.DB) error {
func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
var version uint
err := db.Get(&version, "select version from db_version")
if err != nil {
return fmt.Errorf("couldn't check database version: %w", err)
}
if version > version_number {
return VersionMismatchError{version_number, version}
if version > c.version_number {
return VersionMismatchError{c.version_number, version}
}
if version_number > version {
if c.version_number > version {
fmt.Print(ColorYellow)
fmt.Printf("================================================\n")
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
version_number)
c.version_number)
fmt.Print(ColorReset)
UpgradeFromXToY(db, version, version_number)
c.UpgradeFromXToY(db, version, c.version_number)
}
return nil
}
// Run all the migrations from version X to version Y, and update the `database_version` table's `version_number`
func UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
// UpgradeFromXToY runs all the migrations from version X to version Y, and update the `database_version` table's `version_number`
func (c DBConfig) UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
for i := x; i < y; i++ {
fmt.Print(ColorCyan)
fmt.Println((*migrations)[i])
fmt.Println((*c.migrations)[i])
fmt.Print(ColorReset)
// Execute the migration in a transaction
@@ -105,7 +119,7 @@ func UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
if err != nil {
panic(err)
}
tx.MustExec((*migrations)[i])
tx.MustExec((*c.migrations)[i])
tx.MustExec("update db_version set version = ?", i+1)
if err := tx.Commit(); err != nil {
panic(err)

View File

@@ -20,7 +20,7 @@ func TestCreateAndConnectToDB(t *testing.T) {
schema_sql := string(_schema_sql)
migrations := []string{}
db.Init(&schema_sql, &migrations)
config := db.Init(&schema_sql, &migrations)
data_dir := "../../sample_data/data"
_ = os.MkdirAll(data_dir, os.FileMode(0o644))
@@ -28,9 +28,9 @@ func TestCreateAndConnectToDB(t *testing.T) {
dbPath := filepath.Join(data_dir, "test.db")
_ = os.Remove(dbPath) // Delete it if it exists
_, err = db.Create(dbPath)
_, err = config.Create(dbPath)
assert.NoError(err)
_, err = db.Connect(dbPath)
_, err = config.Connect(dbPath)
assert.NoError(err)
}
@@ -44,42 +44,42 @@ func TestVersionUpgrade(t *testing.T) {
insert into db_version values(0);
`
migrations := []string{}
db.Init(&initial_schema, &migrations)
config := db.Init(&initial_schema, &migrations)
connection, err := db.Create(":memory:")
connection, err := config.Create(":memory:")
require.NoError(err)
get_version := func(c *sqlx.DB) (ret int) {
// TODO: this should be a function exposed by the `db` package itself
c.Get(&ret, "select version from db_version")
require.NoError(c.Get(&ret, "select version from db_version"))
return
}
var items []int
connection.Select(&items, "select * from items")
require.NoError(connection.Select(&items, "select * from items"))
require.Len(items, 0)
require.Equal(0, get_version(connection))
// Create a migration to add a new Item
migrations = append(migrations, "insert into items (rowid) values (1)")
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
db.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
var items2 []int
connection.Select(&items2, "select * from items")
require.NoError(connection.Select(&items2, "select * from items"))
require.Len(items2, 1)
require.Equal(1, get_version(connection))
// Create a migration to add a new Item
migrations = append(migrations, `alter table items add column name string default 'asdf'`)
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
db.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
var items3 []struct {
ID uint64 `db:"rowid"`
Name string `db:"name"`
}
connection.Select(&items3, "select * from items")
require.NoError(connection.Select(&items3, "select * from items"))
require.Len(items2, 1)
assert.Equal(t, "asdf", items3[0].Name)
require.Equal(2, get_version(connection))

87
pkg/db/errors.go Normal file
View File

@@ -0,0 +1,87 @@
package db
import (
"errors"
"fmt"
"github.com/mattn/go-sqlite3"
)
var (
ErrNotInDB = errors.New("not in db")
ErrItemIsDeleted = errors.New("item is deleted")
ErrForeignKeyViolation = errors.New("foreign key constraint failed")
ErrDatabaseAlreadyExists = errors.New("target already exists")
)
type ForeignKey interface {
~int
}
type ForeignKeyError[T ForeignKey] struct {
Field string
TargetTable string
FkValue T
}
func NewForeignKeyError[T ForeignKey](field, table string, fkValue T) ForeignKeyError[T] {
return ForeignKeyError[T]{
Field: field,
TargetTable: table,
FkValue: fkValue,
}
}
func (e ForeignKeyError[T]) Error() string {
return fmt.Sprintf(`%s: fk field %q (to %q) with value "%v"`,
ErrForeignKeyViolation.Error(), e.Field, e.TargetTable, e.FkValue,
)
}
// Unwrap returns the sentinel error-- permits the use of errors.Is for convenience
func (e ForeignKeyError[T]) Unwrap() error {
return ErrForeignKeyViolation
}
// -------------
// SQLite errors
// -------------
// IsSqliteFkError checks whether an error is a SQLite foreign key constraint violation.
func IsSqliteFkError(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) {
return false
}
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintForeignKey)
}
// IsSqliteUniqError checks whether an error is a SQLite unique constraint violation.
func IsSqliteUniqError(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) {
return false
}
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintUnique)
}
// IsSqlitePrimaryKeyError checks whether an error is a SQLite `primary key` constraint violation.
//
// TODO: it's kind of annoying that SQLite returns this instead of whether the violation was
// . uniqueness or null-ness.
func IsSqlitePrimaryKeyError(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) {
return false
}
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey)
}
// IsSqliteNotNullError checks whether an error is a SQLite not null constraint violation.
func IsSqliteNotNullError(err error) bool {
var sqliteErr sqlite3.Error
if !errors.As(err, &sqliteErr) {
return false
}
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintNotNull)
}

64
pkg/db/timestamp.go Normal file
View File

@@ -0,0 +1,64 @@
package db
import (
"database/sql/driver"
"fmt"
"strconv"
"time"
)
// Timestamp is a type that wraps `time.Time`. It implements `driver.Value` and `json.Marshal` to
// store its value as an integer, a Unix timestamp in milliseconds.
//
// It uses type embedding (`struct { time.Time }`), instead of making it simply a typedef of
// `time.Time`, because that keeps methods on `time.Time` (like Add, UnixMilli, etc.) intact and
// exposed on instances of Timestamp.
type Timestamp struct {
time.Time
}
func TimestampFromUnix(num int64) Timestamp {
return Timestamp{time.Unix(num, 0)}
}
func TimestampFromUnixMilli(num int64) Timestamp {
return Timestamp{time.UnixMilli(num)}
}
// TimestampNow returns a new Timestamp corresponding to the current time, rounded to the nearest millisecond.
func TimestampNow() Timestamp {
return Timestamp{time.Now().Round(time.Millisecond)}
}
// ------------
// driver.Value
// ------------
func (t Timestamp) Value() (driver.Value, error) {
return t.UnixMilli(), nil
}
func (t *Timestamp) Scan(src any) error {
val, isOk := src.(int64)
if !isOk {
return fmt.Errorf("incompatible type for Timestamp: %#v", src)
}
*t = TimestampFromUnixMilli(val)
return nil
}
// ------------------------
// json.Marshal / Unmarshal
// ------------------------
func (t Timestamp) MarshalJSON() ([]byte, error) {
return fmt.Appendf(nil, "%d", t.UnixMilli()), nil
}
func (t *Timestamp) UnmarshalJSON(b []byte) error {
ms, err := strconv.ParseInt(string(b), 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp %q: %w", string(b), err)
}
*t = TimestampFromUnixMilli(ms)
return nil
}

View File

@@ -0,0 +1,12 @@
package flowutils
func PanicIf(err error) {
if err != nil {
panic(err)
}
}
func Must[T any](val T, err error) T {
PanicIf(err)
return val
}

207
pkg/schema/lint/checks.go Normal file
View File

@@ -0,0 +1,207 @@
package lint
import (
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
)
type Check struct {
Name string
Explanation string
Execute func(schema.Schema) []CheckResult
}
// CheckResult represents a row in the query result with error message, table name, and column name.
type CheckResult struct {
ErrorMsg string `db:"error_msg"`
TableName string `db:"table_name"`
ColumnName string `db:"column_name"`
}
var Checks = []Check{
{
Name: "require_not_null",
Explanation: "All columns should be marked as `not null` unless they are foreign keys. (Primary keys are\n" +
"automatically not-null, and don't need to be specified.)",
Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
for _, column := range s.Tables[tablename].Columns {
if !column.IsNotNull && !column.IsForeignKey && !column.IsPrimaryKey {
ret = append(ret, CheckResult{
ErrorMsg: "Column should be \"not null\"",
TableName: tablename,
ColumnName: column.Name,
})
}
}
}
return
},
},
{
Name: "require_strict",
Explanation: "All tables should be marked as `strict` (must specify column types; types must be int,\n" +
"integer, real, text, blob or any). This disallows all 'date' and 'time' column types.\n" +
"See more: https://www.sqlite.org/stricttables.html",
Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
if !s.Tables[tablename].IsStrict {
ret = append(ret, CheckResult{
ErrorMsg: "Table should be marked \"strict\"",
TableName: tablename,
ColumnName: "",
})
}
}
return
},
},
{
Name: "forbid_int_type",
Explanation: "All columns should use `integer` type instead of `int`.",
Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename := range s.Tables {
for _, column := range s.Tables[tablename].Columns {
if column.Type == "int" {
ret = append(ret, CheckResult{
ErrorMsg: "Column should use \"integer\" type instead of \"int\"",
TableName: tablename,
ColumnName: column.Name,
})
}
}
}
return
},
},
{
Name: "require_explicit_primary_key",
Explanation: "All tables must have a primary key. If it's rowid, it has to be named explicitly.",
Execute: func(s schema.Schema) (ret []CheckResult) {
tableloop:
for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
for _, column := range s.Tables[tablename].Columns {
if column.IsPrimaryKey {
continue tableloop
}
}
ret = append(ret, CheckResult{
ErrorMsg: "Table should declare an explicit primary key",
TableName: tablename,
ColumnName: "",
})
}
return
},
},
{
Name: "require_indexes_for_foreign_keys",
Explanation: "Columns referenced by foreign keys must have indexes.",
Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename := range s.Tables {
fk_loop:
for _, column := range s.Tables[tablename].Columns {
if !column.IsForeignKey {
continue
}
// Check if target column is a primary key
for _, target_col := range s.Tables[column.ForeignKeyTargetTable].Columns {
if target_col.Name == column.ForeignKeyTargetColumn && target_col.IsPrimaryKey {
continue fk_loop
}
}
// Check if target column is at the beginning of any index
for _, idx := range s.Indexes {
if idx.TableName != column.ForeignKeyTargetTable {
continue
}
for _, idx_col_name := range idx.Columns {
if column.ForeignKeyTargetColumn == idx_col_name {
continue fk_loop
}
break // Only look at 1st column
}
}
ret = append(ret, CheckResult{
ErrorMsg: "Foreign keys should point to indexed columns",
TableName: tablename,
ColumnName: column.Name,
})
}
}
return
},
},
{
Name: "forbid_rowid_on_without_rowid_table",
Explanation: "Tables that are `without rowid` may not have a `rowid` column",
Execute: func(s schema.Schema) (ret []CheckResult) {
for tblName, tbl := range s.Tables {
if !tbl.IsWithoutRowid {
continue
}
for _, column := range tbl.Columns {
if column.Name == "rowid" {
ret = append(ret, CheckResult{
ErrorMsg: "rowid on 'without rowid' table",
TableName: tblName,
ColumnName: column.Name,
})
}
}
}
return
},
},
{
Name: "require_explicit_rowid",
Explanation: "All tables should have an explicit `rowid integer primary key` column, unless they\n" +
"are declared `without rowid`.",
Execute: func(s schema.Schema) (ret []CheckResult) {
tbl_loop:
for tblName, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
if tbl.IsWithoutRowid {
continue
}
for _, column := range tbl.Columns {
if column.Name == "rowid" {
if !column.IsPrimaryKey {
ret = append(ret, CheckResult{
ErrorMsg: "`rowid` column not declared \"primary key\"",
TableName: tblName,
ColumnName: column.Name,
})
}
if column.Type != "integer" {
ret = append(ret, CheckResult{
ErrorMsg: "non-integer `rowid` column",
TableName: tblName,
ColumnName: column.Name,
})
}
continue tbl_loop
}
}
ret = append(ret, CheckResult{
ErrorMsg: "no `rowid` column",
TableName: tblName,
ColumnName: "",
})
}
return
},
},
}

View File

@@ -0,0 +1,75 @@
package lint_test
import (
"slices"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/require"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
)
func TestFailureCases(t *testing.T) {
test_cases := []struct {
sqlFile string
expectedFailures []string
}{
{"test_schemas/failure-has-foreign-key-no-index.sql", []string{"require_indexes_for_foreign_keys"}},
{"test_schemas/failure-has-ints.sql", []string{"forbid_int_type"}},
{"test_schemas/failure-has-nulls.sql", []string{"require_not_null"}},
{"test_schemas/failure-no-strict.sql", []string{"require_strict"}},
{"test_schemas/failure-rowid-on-without-rowid.sql", []string{"forbid_rowid_on_without_rowid_table"}},
{"test_schemas/failure-missing-rowid.sql", []string{"require_explicit_rowid"}},
{"test_schemas/failure-non-integer-rowid.sql", []string{"require_explicit_rowid"}},
{"test_schemas/failure-non-primary-key-rowid.sql", []string{"require_explicit_rowid"}},
{"test_schemas/failure-total.sql", []string{
"require_not_null",
"require_explicit_primary_key",
"forbid_int_type",
"require_strict",
"require_indexes_for_foreign_keys",
"require_explicit_rowid",
}},
}
for _, test_case := range test_cases {
t.Run(test_case.sqlFile, func(t *testing.T) {
schema, err := schema.SchemaFromSQLFile(test_case.sqlFile)
require.NoError(t, err)
for _, check := range lint.Checks {
results := check.Execute(schema)
is_failure := len(results) > 0
is_failure_expected := slices.Contains(test_case.expectedFailures, check.Name)
if is_failure != is_failure_expected {
if is_failure_expected {
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, test_case.sqlFile)
} else {
t.Errorf("Expected check '%s' to pass, but it failed: %s (%q.%q)",
check.Name,
test_case.sqlFile,
results[0].TableName, results[0].ColumnName,
)
}
}
}
})
}
}
func TestSuccessCase(t *testing.T) {
file := "test_schemas/success.sql"
schema, err := schema.SchemaFromSQLFile(file)
require.NoError(t, err)
for _, check := range lint.Checks {
results := check.Execute(schema)
for _, r := range results {
t.Errorf("Unexpected error in file %q: %#v", file, r)
}
}
}

View File

@@ -0,0 +1,12 @@
create table stuff (
rowid integer primary key,
data text not null,
amount integer not null
) strict;
create table stuff2 (
rowid integer primary key,
label text not null unique,
stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount)
) strict;

View File

@@ -0,0 +1,13 @@
create table stuff (
rowid integer primary key,
data text not null,
amount integer not null
) strict;
create index index_stuff_amount on stuff (amount);
create table stuff2 (
rowid integer primary key,
label text not null unique,
stuff_id int references stuff(rowid),
alternative_stuff_id integer references stuff(amount)
) strict;

View File

@@ -0,0 +1,13 @@
create table stuff (
rowid integer primary key,
data text not null,
amount integer
) strict;
create index index_stuff_amount on stuff (amount);
create table stuff2 (
rowid integer primary key,
label text not null unique,
stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount)
) strict;

View File

@@ -0,0 +1,3 @@
create table missing_rowid (
a integer primary key
) strict;

View File

@@ -0,0 +1,13 @@
create table stuff (
rowid integer primary key,
data text not null,
amount integer not null
) strict;
create index index_stuff_amount on stuff (amount);
create table stuff2 (
rowid integer primary key,
label text not null unique,
stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount)
);

View File

@@ -0,0 +1,4 @@
create table non_integer_rowid (
rowid text primary key,
a integer not null
) strict;

View File

@@ -0,0 +1,4 @@
create table withrowidbutnotprimarykey (
rowid integer not null,
a integer primary key
) strict;

View File

@@ -0,0 +1,4 @@
create table weirdrowid (
rowid integer primary key,
a integer not null
) strict, without rowid;

View File

@@ -0,0 +1,28 @@
PRAGMA foreign_keys = on;
create table implicit_rowid (
a integer
);
create table explicit_rowid_not_pk (
rowid integer
);
create table explicit_rowid (
rowid integer primary key
);
create table without_rowid (
a integer primary key
) without rowid;
create table multi_column (
a int,
b integer,
primary key(a, b)
);
create table foreign_key_missing_index (
a references implicit_rowid(a)
);

View File

@@ -0,0 +1,26 @@
create table stuff (
rowid integer primary key,
data text not null,
amount integer not null
) strict;
create index index_stuff_amount on stuff (amount);
create virtual table stuff_fts using fts5(
data,
content='stuff',
content_rowid='rowid',
tokenize='trigram'
);
create table stuff2 (
weird_pk integer primary key,
label text not null unique,
stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount)
) strict, without rowid;
create table stuff3 (
rowid integer primary key,
stuff2_id integer not null references stuff2(weird_pk)
) strict;

View File

@@ -1,64 +1,62 @@
package schema
import (
_ "embed"
"fmt"
"os"
"strings"
"github.com/jinzhu/inflection"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
//go:embed views.sql
var create_views string
// InitDB creates an in-memory DB from a given schema string.
func InitDB(sql_schema string) *sqlx.DB {
db := sqlx.MustOpen("sqlite3", ":memory:")
db.MustExec(sql_schema)
db.MustExec(`
create temporary view tables as
select l.schema, l.name, l.type, l.ncol, l.wr, l.strict
from sqlite_schema s
left join pragma_table_list l on s.name = l.name
where s.type = 'table';
create temporary view columns as
select tables.name as table_name,
table_info.name as column_name,
lower(table_info.type) as column_type,
"notnull",
dflt_value is not null as has_default_value,
ifnull(dflt_value, 0) dflt_value,
pk as is_primary_key,
fk."table" is not null as is_foreign_key,
ifnull(fk."table", '') as fk_target_table,
ifnull(fk."to", '') as fk_target_column
from tables
join pragma_table_info(tables.name) as table_info
left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name;
`)
db.MustExec(create_views)
return db
}
func SchemaFromSQLFile(filepath string) (Schema, error) {
sqlBytes, err := os.ReadFile(filepath)
if err != nil {
return Schema{}, fmt.Errorf("failed to read SQL file: %w", err)
}
return SchemaFromDB(InitDB(string(sqlBytes))), nil
}
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
func SchemaFromDB(db *sqlx.DB) Schema {
ret := Schema{}
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
var tables []Table
err := db.Select(&tables, `select name, strict from tables`)
if err != nil {
panic(err)
}
PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
for _, tbl := range tables {
tbl.TypeName = textutils.SnakeToCamel(inflection.Singular(tbl.TableName))
tbl.TypeIDName = tbl.TypeName + "ID"
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
tbl.TypeIDName = tbl.GoTypeName + "ID"
tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
err := db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName)
if err != nil {
panic(err)
}
ret[tbl.TableName] = tbl
PanicIf(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
ret.Tables[tbl.TableName] = tbl
}
var indexes []Index
PanicIf(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
for _, idx := range indexes {
PanicIf(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
ret.Indexes[idx.Name] = idx
}
return ret
}
func TypenameFromTablename(tablename string) string {
return textutils.SnakeToCamel(inflection.Singular(tablename))
}

View File

@@ -19,13 +19,13 @@ func TestParseSchema(t *testing.T) {
schema := schema.SchemaFromDB(db)
expected_tbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
for _, tbl_name := range expected_tbls {
_, is_ok := schema[tbl_name]
_, is_ok := schema.Tables[tbl_name]
assert.True(is_ok)
}
foods := schema["foods"]
foods := schema.Tables["foods"]
assert.Equal(foods.TableName, "foods")
assert.Equal(foods.TypeName, "Food")
assert.Equal(foods.GoTypeName, "Food")
assert.Equal(foods.TypeIDName, "FoodID")
assert.Equal(foods.IsStrict, true)
assert.Len(foods.Columns, 20)
@@ -44,11 +44,36 @@ func TestParseSchema(t *testing.T) {
assert.Equal(foods.Columns[16].HasDefaultValue, true)
assert.Equal(foods.Columns[16].DefaultValue, "100")
ingredients := schema["ingredients"]
ingredients := schema.Tables["ingredients"]
assert.Equal(ingredients.Columns[0].Name, "rowid")
assert.Equal(ingredients.Columns[0].IsPrimaryKey, true)
assert.Equal(ingredients.Columns[1].Name, "food_id")
assert.Equal(ingredients.Columns[1].IsForeignKey, true)
assert.Equal(ingredients.Columns[1].ForeignKeyTargetTable, "foods")
assert.Equal(ingredients.Columns[1].ForeignKeyTargetColumn, "rowid")
// -------
// Indexes
// -------
// Unique index
units_name_idx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
require.True(t, isOk)
assert.True(units_name_idx.IsUnique)
assert.Equal("units", units_name_idx.TableName)
assert.Equal([]string{"name"}, units_name_idx.Columns)
// Non-unique, declared index
foods_protein_idx, isOk := schema.Indexes["foods_protein"]
require.True(t, isOk)
assert.False(foods_protein_idx.IsUnique)
assert.Equal("foods", foods_protein_idx.TableName)
assert.Equal([]string{"protein"}, foods_protein_idx.Columns)
// Multi-column index
ingr_seq_idx, isOk := schema.Indexes["sqlite_autoindex_ingredients_1"]
require.True(t, isOk)
assert.True(ingr_seq_idx.IsUnique)
assert.Equal("ingredients", ingr_seq_idx.TableName)
assert.Equal([]string{"in_recipe_id", "list_order"}, ingr_seq_idx.Columns)
}

View File

@@ -9,6 +9,7 @@ type Column struct {
HasDefaultValue bool `db:"has_default_value"`
DefaultValue string `db:"dflt_value"`
IsPrimaryKey bool `db:"is_primary_key"`
PrimaryKeyRank uint `db:"primary_key_rank"`
IsForeignKey bool `db:"is_foreign_key"`
ForeignKeyTargetTable string `db:"fk_target_table"`
ForeignKeyTargetColumn string `db:"fk_target_column"`
@@ -22,13 +23,33 @@ func (c Column) IsNullableForeignKey() bool {
// Table is a single SQLite table.
type Table struct {
TableName string `db:"name"`
IsStrict bool `db:"strict"`
Columns []Column
// One of "table", "view", "shadow", or "virtual"
TableType string `db:"table_type"`
IsStrict bool `db:"is_strict"`
IsWithoutRowid bool `db:"is_without_rowid"`
Columns []Column
TypeIDName string
VarName string
TypeName string
// Default variable name for variables of this type to use when generating Go code
VarName string
// Name of corresponding model type to be generated
GoTypeName string
}
// Schema is a container for a bunch of Tables, indexed by table name.
type Schema map[string]Table
type Index struct {
Name string `db:"index_name"`
TableName string `db:"table_name"`
Columns []string
IsUnique bool `db:"is_unique"`
// TODO: `where ...` for partial indexes
// TODO: identify columns that are expressions
}
type Schema struct {
Tables map[string]Table
Indexes map[string]Index
}

40
pkg/schema/views.sql Normal file
View File

@@ -0,0 +1,40 @@
create temporary view tables as
select l.schema,
l.name,
l.type as table_type,
l.wr as is_without_rowid,
l.strict as is_strict
from sqlite_schema s
left join pragma_table_list l on s.name = l.name
where s.type = 'table';
create temporary view columns as
select tables.name as table_name,
table_info.name as column_name,
lower(table_info.type) as column_type,
"notnull",
dflt_value is not null as has_default_value,
ifnull(dflt_value, 0) dflt_value,
pk != 0 as is_primary_key,
pk as primary_key_rank,
fk."table" is not null as is_foreign_key,
ifnull(fk."table", '') as fk_target_table,
ifnull(fk."to", '') as fk_target_column
from tables
join pragma_table_info(tables.name) as table_info
left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name;
create temporary view indexes as
select idx.name as index_name,
tables.name as table_name,
idx."unique" as is_unique
from tables
join pragma_index_list(tables.name) idx;
create temporary view index_columns as
select indexes.index_name,
idx_cols.cid as column_idx,
ifnull(idx_cols.name, '') as column_name,
idx_cols.seqno as rank
from indexes
join pragma_index_info(indexes.index_name) as idx_cols;

View File

@@ -42,6 +42,8 @@ create table foods (rowid integer primary key,
density real not null default 1,
cook_ratio real not null default 1
) strict;
create index foods_protein on foods(protein);
create index foods_fake_index on foods(protein + 1);
create table units (rowid integer primary key,