55 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
51 changed files with 2019 additions and 73 deletions

View File

@@ -3,9 +3,61 @@ name: CI
on: [push] on: [push]
jobs: 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: 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:
GOPATH: /go-cache-volume
GOCACHE: /go-cache-volume/build-cache
steps:
- name: checkout
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
env
apk add go
GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
gocheckout
- 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: volumes:
- woodpecker-gocache:/go-cache-volume - woodpecker-gocache:/go-cache-volume
env: env:
@@ -18,9 +70,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
env env
GOBIN=/usr/local/go/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.1 ls -la /usr/local/bin
gocheckout gocheckout
- name: test - name: test
run: go test ./... run: go test -tags fts5 ./...
- name: lint - name: lint
run: golangci-lint run 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

View File

@@ -58,9 +58,10 @@ linters:
checks: checks:
- all - all
- -ST1000 # Re-enable this once we have docstrings - -ST1000 # Re-enable this once we have docstrings
- -ST1001 # Dot imports are good sometimes (e.g., in test packages)
- -ST1003 # I like snake_case - -ST1003 # I like snake_case
- -ST1013 # HTTP status codes are shorter and more readable than names - -ST1013 # HTTP status codes are shorter and more readable than names
dot-import-whitelist:
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
exclusions: exclusions:
generated: lax # Don't lint generated files generated: lax # Don't lint generated files
paths: paths:

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/jinzhu/inflection v1.0.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect 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/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= 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,7 +10,7 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
var ( type DBConfig struct {
// // Tracks whether the DB connector has been initialized // // Tracks whether the DB connector has been initialized
// is_initialized bool // is_initialized bool
@@ -20,11 +20,7 @@ var (
// Database starts at version 0. First migration brings us to version 1 // Database starts at version 0. First migration brings us to version 1
migrations *[]string migrations *[]string
version_number uint version_number uint
) }
var (
ErrTargetExists = errors.New("target already exists")
)
// Colors for terminal output // Colors for terminal output
const ( const (
@@ -40,64 +36,82 @@ const (
ColorWhite = "\033[97m" ColorWhite = "\033[97m"
) )
func Init(schema *string, migrationsList *[]string) { func Init(schema *string, migrationsList *[]string) DBConfig {
sql_schema = schema return DBConfig{
migrations = migrationsList sql_schema: schema,
version_number = uint(len(*migrations)) migrations: migrationsList,
// is_initialized = true 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 // First check if the path already exists
_, err := os.Stat(path) _, err := os.Stat(path)
if err == nil { if err == nil {
return nil, ErrTargetExists return nil, ErrDatabaseAlreadyExists
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("path error: %w", err) return nil, fmt.Errorf("path error: %w", err)
} }
// Create DB file // Create DB file
fmt.Printf("Creating............. %s\n", path) fmt.Printf("Creating............. %s\n", path)
db := sqlx.MustOpen("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL") db, err := sqlx.Open("sqlite3", path)
db.MustExec(*sql_schema) 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 return db, nil
} }
func Connect(path string) (*sqlx.DB, error) { func (c DBConfig) Connect(path string) (*sqlx.DB, error) {
db := sqlx.MustOpen("sqlite3", fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL", path)) db, err := sqlx.Open("sqlite3", path)
err := CheckAndUpdateVersion(db) 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 return db, err
} }
func CheckAndUpdateVersion(db *sqlx.DB) error { func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
var version uint var version uint
err := db.Get(&version, "select version from db_version") err := db.Get(&version, "select version from db_version")
if err != nil { if err != nil {
return fmt.Errorf("couldn't check database version: %w", err) return fmt.Errorf("couldn't check database version: %w", err)
} }
if version > version_number { if version > c.version_number {
return VersionMismatchError{version_number, version} return VersionMismatchError{c.version_number, version}
} }
if version_number > version { if c.version_number > version {
fmt.Print(ColorYellow) fmt.Print(ColorYellow)
fmt.Printf("================================================\n") fmt.Printf("================================================\n")
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version, fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
version_number) c.version_number)
fmt.Print(ColorReset) fmt.Print(ColorReset)
UpgradeFromXToY(db, version, version_number) c.UpgradeFromXToY(db, version, c.version_number)
} }
return nil return nil
} }
// UpgradeFromXToY runs all the migrations from version X to version Y, and update the `database_version` table's `version_number` // UpgradeFromXToY runs 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) { func (c DBConfig) UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
for i := x; i < y; i++ { for i := x; i < y; i++ {
fmt.Print(ColorCyan) fmt.Print(ColorCyan)
fmt.Println((*migrations)[i]) fmt.Println((*c.migrations)[i])
fmt.Print(ColorReset) fmt.Print(ColorReset)
// Execute the migration in a transaction // Execute the migration in a transaction
@@ -105,7 +119,7 @@ func UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
if err != nil { if err != nil {
panic(err) panic(err)
} }
tx.MustExec((*migrations)[i]) tx.MustExec((*c.migrations)[i])
tx.MustExec("update db_version set version = ?", i+1) tx.MustExec("update db_version set version = ?", i+1)
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
panic(err) panic(err)

View File

@@ -20,7 +20,7 @@ func TestCreateAndConnectToDB(t *testing.T) {
schema_sql := string(_schema_sql) schema_sql := string(_schema_sql)
migrations := []string{} migrations := []string{}
db.Init(&schema_sql, &migrations) config := db.Init(&schema_sql, &migrations)
data_dir := "../../sample_data/data" data_dir := "../../sample_data/data"
_ = os.MkdirAll(data_dir, os.FileMode(0o644)) _ = os.MkdirAll(data_dir, os.FileMode(0o644))
@@ -28,9 +28,9 @@ func TestCreateAndConnectToDB(t *testing.T) {
dbPath := filepath.Join(data_dir, "test.db") dbPath := filepath.Join(data_dir, "test.db")
_ = os.Remove(dbPath) // Delete it if it exists _ = os.Remove(dbPath) // Delete it if it exists
_, err = db.Create(dbPath) _, err = config.Create(dbPath)
assert.NoError(err) assert.NoError(err)
_, err = db.Connect(dbPath) _, err = config.Connect(dbPath)
assert.NoError(err) assert.NoError(err)
} }
@@ -44,9 +44,9 @@ func TestVersionUpgrade(t *testing.T) {
insert into db_version values(0); insert into db_version values(0);
` `
migrations := []string{} 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) require.NoError(err)
get_version := func(c *sqlx.DB) (ret int) { get_version := func(c *sqlx.DB) (ret int) {
@@ -63,7 +63,7 @@ func TestVersionUpgrade(t *testing.T) {
// Create a migration to add a new Item // Create a migration to add a new Item
migrations = append(migrations, "insert into items (rowid) values (1)") migrations = append(migrations, "insert into items (rowid) values (1)")
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration 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 var items2 []int
require.NoError(connection.Select(&items2, "select * from items")) require.NoError(connection.Select(&items2, "select * from items"))
@@ -72,8 +72,8 @@ func TestVersionUpgrade(t *testing.T) {
// Create a migration to add a new Item // Create a migration to add a new Item
migrations = append(migrations, `alter table items add column name string default 'asdf'`) migrations = append(migrations, `alter table items add column name string default 'asdf'`)
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration config = 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 items3 []struct { var items3 []struct {
ID uint64 `db:"rowid"` ID uint64 `db:"rowid"`

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
}

View File

@@ -23,7 +23,10 @@ var Checks = []Check{
Explanation: "All columns should be marked as `not null` unless they are foreign keys. (Primary keys are\n" + 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.)", "automatically not-null, and don't need to be specified.)",
Execute: func(s schema.Schema) (ret []CheckResult) { Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename := range s.Tables { for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
for _, column := range s.Tables[tablename].Columns { for _, column := range s.Tables[tablename].Columns {
if !column.IsNotNull && !column.IsForeignKey && !column.IsPrimaryKey { if !column.IsNotNull && !column.IsForeignKey && !column.IsPrimaryKey {
ret = append(ret, CheckResult{ ret = append(ret, CheckResult{
@@ -43,7 +46,10 @@ var Checks = []Check{
"integer, real, text, blob or any). This disallows all 'date' and 'time' column types.\n" + "integer, real, text, blob or any). This disallows all 'date' and 'time' column types.\n" +
"See more: https://www.sqlite.org/stricttables.html", "See more: https://www.sqlite.org/stricttables.html",
Execute: func(s schema.Schema) (ret []CheckResult) { Execute: func(s schema.Schema) (ret []CheckResult) {
for tablename := range s.Tables { for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
if !s.Tables[tablename].IsStrict { if !s.Tables[tablename].IsStrict {
ret = append(ret, CheckResult{ ret = append(ret, CheckResult{
ErrorMsg: "Table should be marked \"strict\"", ErrorMsg: "Table should be marked \"strict\"",
@@ -78,7 +84,10 @@ var Checks = []Check{
Explanation: "All tables must have a primary key. If it's rowid, it has to be named explicitly.", 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) { Execute: func(s schema.Schema) (ret []CheckResult) {
tableloop: tableloop:
for tablename := range s.Tables { for tablename, tbl := range s.Tables {
if tbl.TableType != "table" {
continue
}
for _, column := range s.Tables[tablename].Columns { for _, column := range s.Tables[tablename].Columns {
if column.IsPrimaryKey { if column.IsPrimaryKey {
continue tableloop continue tableloop
@@ -133,4 +142,66 @@ var Checks = []Check{
return 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

@@ -20,12 +20,17 @@ func TestFailureCases(t *testing.T) {
{"test_schemas/failure-has-ints.sql", []string{"forbid_int_type"}}, {"test_schemas/failure-has-ints.sql", []string{"forbid_int_type"}},
{"test_schemas/failure-has-nulls.sql", []string{"require_not_null"}}, {"test_schemas/failure-has-nulls.sql", []string{"require_not_null"}},
{"test_schemas/failure-no-strict.sql", []string{"require_strict"}}, {"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{ {"test_schemas/failure-total.sql", []string{
"require_not_null", "require_not_null",
"require_explicit_primary_key", "require_explicit_primary_key",
"forbid_int_type", "forbid_int_type",
"require_strict", "require_strict",
"require_indexes_for_foreign_keys", "require_indexes_for_foreign_keys",
"require_explicit_rowid",
}}, }},
} }

View File

@@ -5,7 +5,7 @@ create table stuff (
) strict; ) strict;
create table stuff2 ( create table stuff2 (
weird_pk integer primary key, rowid integer primary key,
label text not null unique, label text not null unique,
stuff_id integer references stuff(rowid), stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount) alternative_stuff_id integer references stuff(amount)

View File

@@ -6,7 +6,7 @@ create table stuff (
create index index_stuff_amount on stuff (amount); create index index_stuff_amount on stuff (amount);
create table stuff2 ( create table stuff2 (
weird_pk integer primary key, rowid integer primary key,
label text not null unique, label text not null unique,
stuff_id int references stuff(rowid), stuff_id int references stuff(rowid),
alternative_stuff_id integer references stuff(amount) alternative_stuff_id integer references stuff(amount)

View File

@@ -6,7 +6,7 @@ create table stuff (
create index index_stuff_amount on stuff (amount); create index index_stuff_amount on stuff (amount);
create table stuff2 ( create table stuff2 (
weird_pk integer primary key, rowid integer primary key,
label text not null unique, label text not null unique,
stuff_id integer references stuff(rowid), stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount) alternative_stuff_id integer references stuff(amount)

View File

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

View File

@@ -6,7 +6,7 @@ create table stuff (
create index index_stuff_amount on stuff (amount); create index index_stuff_amount on stuff (amount);
create table stuff2 ( create table stuff2 (
weird_pk integer primary key, rowid integer primary key,
label text not null unique, label text not null unique,
stuff_id integer references stuff(rowid), stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount) 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

@@ -5,14 +5,22 @@ create table stuff (
) strict; ) strict;
create index index_stuff_amount on stuff (amount); 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 ( create table stuff2 (
weird_pk integer primary key, weird_pk integer primary key,
label text not null unique, label text not null unique,
stuff_id integer references stuff(rowid), stuff_id integer references stuff(rowid),
alternative_stuff_id integer references stuff(amount) alternative_stuff_id integer references stuff(amount)
) strict; ) strict, without rowid;
create table stuff3 ( create table stuff3 (
weird_pk3 integer primary key, rowid integer primary key,
stuff2_id integer not null references stuff2(weird_pk) stuff2_id integer not null references stuff2(weird_pk)
) strict; ) strict;

View File

@@ -10,6 +10,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils" "git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
) )
@@ -37,33 +38,25 @@ func SchemaFromDB(db *sqlx.DB) Schema {
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}} ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
var tables []Table var tables []Table
err := db.Select(&tables, `select name, is_strict, is_without_rowid from tables`) PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
if err != nil {
panic(err)
}
for _, tbl := range tables { for _, tbl := range tables {
tbl.TypeName = textutils.SnakeToCamel(inflection.Singular(tbl.TableName)) tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
tbl.TypeIDName = tbl.TypeName + "ID" tbl.TypeIDName = tbl.GoTypeName + "ID"
tbl.VarName = strings.ToLower(string(tbl.TableName[0])) tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
err = db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName) PanicIf(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
if err != nil {
panic(err)
}
ret.Tables[tbl.TableName] = tbl ret.Tables[tbl.TableName] = tbl
} }
var indexes []Index var indexes []Index
err = db.Select(&indexes, `select index_name, table_name, is_unique from indexes`) PanicIf(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
if err != nil {
panic(err)
}
for _, idx := range indexes { for _, idx := range indexes {
err = db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name) PanicIf(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
if err != nil {
panic(err)
}
ret.Indexes[idx.Name] = idx ret.Indexes[idx.Name] = idx
} }
return ret return ret
} }
func TypenameFromTablename(tablename string) string {
return textutils.SnakeToCamel(inflection.Singular(tablename))
}

View File

@@ -25,7 +25,7 @@ func TestParseSchema(t *testing.T) {
foods := schema.Tables["foods"] foods := schema.Tables["foods"]
assert.Equal(foods.TableName, "foods") assert.Equal(foods.TableName, "foods")
assert.Equal(foods.TypeName, "Food") assert.Equal(foods.GoTypeName, "Food")
assert.Equal(foods.TypeIDName, "FoodID") assert.Equal(foods.TypeIDName, "FoodID")
assert.Equal(foods.IsStrict, true) assert.Equal(foods.IsStrict, true)
assert.Len(foods.Columns, 20) assert.Len(foods.Columns, 20)

View File

@@ -23,14 +23,21 @@ func (c Column) IsNullableForeignKey() bool {
// Table is a single SQLite table. // Table is a single SQLite table.
type Table struct { type Table struct {
TableName string `db:"name"` TableName string `db:"name"`
// One of "table", "view", "shadow", or "virtual"
TableType string `db:"table_type"`
IsStrict bool `db:"is_strict"` IsStrict bool `db:"is_strict"`
IsWithoutRowid bool `db:"is_without_rowid"` IsWithoutRowid bool `db:"is_without_rowid"`
Columns []Column Columns []Column
TypeIDName string TypeIDName string
// Default variable name for variables of this type to use when generating Go code
VarName string VarName string
TypeName string
// Name of corresponding model type to be generated
GoTypeName string
} }
type Index struct { type Index struct {
@@ -39,6 +46,7 @@ type Index struct {
Columns []string Columns []string
IsUnique bool `db:"is_unique"` IsUnique bool `db:"is_unique"`
// TODO: `where ...` for partial indexes // TODO: `where ...` for partial indexes
// TODO: identify columns that are expressions
} }
type Schema struct { type Schema struct {

View File

@@ -1,7 +1,7 @@
create temporary view tables as create temporary view tables as
select l.schema, select l.schema,
l.name, l.name,
l.type, l.type as table_type,
l.wr as is_without_rowid, l.wr as is_without_rowid,
l.strict as is_strict l.strict as is_strict
from sqlite_schema s from sqlite_schema s
@@ -33,7 +33,8 @@ create temporary view indexes as
create temporary view index_columns as create temporary view index_columns as
select indexes.index_name, select indexes.index_name,
idx_cols.name as column_name, idx_cols.cid as column_idx,
ifnull(idx_cols.name, '') as column_name,
idx_cols.seqno as rank idx_cols.seqno as rank
from indexes from indexes
join pragma_index_info(indexes.index_name) as idx_cols; join pragma_index_info(indexes.index_name) as idx_cols;

View File

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