Compare commits
62 Commits
14d395f0ea
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08f12e275 | ||
|
|
0371fb4144 | ||
| d70cbc1913 | |||
| ec19c3dcb5 | |||
| 2df2c634cd | |||
| 45c9e15dd3 | |||
| dc33bd84ff | |||
| 2d29ce92ec | |||
| a6cbbd3b39 | |||
| 2ac9d8e775 | |||
| 560e461b00 | |||
| 5c1e1dcb6a | |||
| a05cad5144 | |||
| e6a8175644 | |||
| 195c8f980e | |||
| 8b674a0dea | |||
| 13a6255553 | |||
| e0e6046afe | |||
| 381d648be0 | |||
| ded07eb182 | |||
| a32bf873c9 | |||
| d9df69eccf | |||
| 89db9e14e2 | |||
| f74da53c37 | |||
| 6358d12d10 | |||
| 1cbc99307a | |||
| fef3f11342 | |||
| 208c1eb26c | |||
| 2146e1de77 | |||
| a81dfb69a5 | |||
| bfb7073cf6 | |||
| 76f674d63b | |||
| d1c00f9455 | |||
| 23dc2c7899 | |||
| d0f919d69a | |||
| 8a1b7a9f34 | |||
| fef24e2364 | |||
| 61fa4e5cc4 | |||
| 22ee5e181a | |||
| be351bbb7d | |||
| 4c266ce336 | |||
| da85d89e9b | |||
| 98c6875262 | |||
| 912210cc90 | |||
| dbde3c8724 | |||
| b7b581ff5a | |||
| 6a837d28c6 | |||
| 8c6d8354df | |||
| 6624b5794f | |||
| 2c8b842f17 | |||
| 0bc951748d | |||
| 5a0ecdec1c | |||
| e888fe0ec1 | |||
| c402c775a3 | |||
| 87f5471b76 | |||
| 7449e7dc10 | |||
| 100b53d799 | |||
| 8b422ada11 | |||
| a4de16a1dd | |||
| 53785b6e6e | |||
| 8245f89305 | |||
| e7f9934f03 |
@@ -3,9 +3,35 @@ name: CI
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
release-test:
|
||||
# These steps build the `gas` docker image.
|
||||
# There's a tricky bootstrapping problem here. We'll try to cheat first, and if that doesn't
|
||||
# work, then we'll do a full bootstrapping build starting from Alpine and installing `gocheckout`
|
||||
# first thing.
|
||||
build-docker:
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: offline-twitter/go
|
||||
image: gas
|
||||
volumes:
|
||||
- woodpecker-gocache:/go-cache-volume
|
||||
env:
|
||||
GOPATH: /go-cache-volume
|
||||
GOCACHE: /go-cache-volume/build-cache
|
||||
steps:
|
||||
- name: checkout
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gocheckout
|
||||
|
||||
- name: build CI container
|
||||
run: ops/devcontainer/build.sh
|
||||
|
||||
# Fallback: full bootstrap build. This one is quite slow.
|
||||
build-docker-bootstrap:
|
||||
needs: build-docker
|
||||
if: failure()
|
||||
|
||||
container:
|
||||
image: alpine:3.22
|
||||
volumes:
|
||||
- woodpecker-gocache:/go-cache-volume
|
||||
env:
|
||||
@@ -17,8 +43,47 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
env
|
||||
GOBIN=/usr/local/go/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.1
|
||||
apk add go
|
||||
GOBIN=/usr/local/bin go install git.offline-twitter.com/offline-labs/gocheckout@v0.0.2
|
||||
gocheckout
|
||||
- name: test
|
||||
|
||||
- name: install docker
|
||||
run: apk add sudo docker
|
||||
|
||||
- name: build CI container
|
||||
run: ops/devcontainer/build.sh
|
||||
|
||||
release-test:
|
||||
needs: build-docker-bootstrap
|
||||
if: "!failure()"
|
||||
container:
|
||||
image: gas
|
||||
volumes:
|
||||
- woodpecker-gocache:/go-cache-volume
|
||||
env:
|
||||
GOPATH: /go-cache-volume
|
||||
GOCACHE: /go-cache-volume/build-cache
|
||||
GOLANGCI_LINT_CACHE: /go-cache-volume/golangci-lint
|
||||
steps:
|
||||
- name: checkout
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
go test ./...
|
||||
env
|
||||
ls -la /usr/local/bin
|
||||
gocheckout
|
||||
|
||||
- name: test
|
||||
run: go test -tags fts5 ./...
|
||||
|
||||
- name: lint
|
||||
run: golangci-lint run
|
||||
|
||||
- name: lint shell
|
||||
run: find ops/ -name "*.sh" | xargs shellcheck
|
||||
|
||||
- name: Run sqlite_lint integration test
|
||||
run: ops/sqlite_lint_test.sh
|
||||
|
||||
- name: Run 'gas init' and 'gas generate'
|
||||
run: ops/gas_init_test.sh
|
||||
|
||||
92
.golangci.yaml
Normal file
92
.golangci.yaml
Normal file
@@ -0,0 +1,92 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
# Defaults
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
|
||||
# Extras
|
||||
- depguard
|
||||
- errorlint
|
||||
- godox
|
||||
- lll
|
||||
- nolintlint
|
||||
- sqlclosecheck
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: io/ioutil
|
||||
desc: replace with the matching functions from `io` or `os` packages
|
||||
- pkg: github.com/pkg/errors
|
||||
desc: Should be replaced by standard lib errors package
|
||||
errcheck:
|
||||
# report about not checking of errors in type assertions: `a := b.(MyStruct)`;
|
||||
# default is false: such cases aren't reported by default.
|
||||
check-type-assertions: true
|
||||
errorlint:
|
||||
errorf: true # Ensure Errorf only uses %w (not %v or %s etc) for errors
|
||||
asserts: true # Require errors.As instead of type-asserting
|
||||
comparison: true # Require errors.Is instead of equality-checking
|
||||
godox:
|
||||
# report any comments starting with keywords, this is useful for TODO or FIXME comments that
|
||||
# might be left in the code accidentally and should be resolved before merging
|
||||
keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting
|
||||
- XXX
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment
|
||||
lll:
|
||||
line-length: 140
|
||||
tab-width: 4
|
||||
nolintlint:
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
allow-unused: false
|
||||
staticcheck:
|
||||
go: "1.24"
|
||||
checks:
|
||||
- all
|
||||
- -ST1000 # Re-enable this once we have docstrings
|
||||
- -ST1003 # I like snake_case
|
||||
- -ST1013 # HTTP status codes are shorter and more readable than names
|
||||
dot-import-whitelist:
|
||||
- "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
exclusions:
|
||||
generated: lax # Don't lint generated files
|
||||
paths:
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
gofmt:
|
||||
simplify: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
|
||||
run:
|
||||
build-tags:
|
||||
- integration
|
||||
|
||||
issues:
|
||||
max-same-issues: 0
|
||||
max-issues-per-linter: 0
|
||||
uniq-by-line: false
|
||||
29
cmd/main.go
Normal file
29
cmd/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
90
cmd/subcmd_generate_models.go
Normal file
90
cmd/subcmd_generate_models.go
Normal 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
70
cmd/subcmd_init.go
Normal 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
79
cmd/subcmd_sqlite_lint.go
Normal 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
10
doc/3-letter-names
Normal file
@@ -0,0 +1,10 @@
|
||||
## Alternatives to `ops`:
|
||||
|
||||
mng
|
||||
dev
|
||||
run
|
||||
env
|
||||
adm
|
||||
kit
|
||||
wrk
|
||||
hlp
|
||||
17
doc/TODO.txt
Normal file
17
doc/TODO.txt
Normal 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
157
doc/gas-stack.md
Normal 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
7
doc/inspirations.txt
Normal 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
1
doc/links.txt
Normal file
@@ -0,0 +1 @@
|
||||
https://astexplorer.net/
|
||||
41
doc/rails-scaffolding-comparison.md
Normal file
41
doc/rails-scaffolding-comparison.md
Normal 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
|
||||
88
doc/sqlite-schema-rules-and-linter.md
Normal file
88
doc/sqlite-schema-rules-and-linter.md
Normal 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
63
doc/using-devcontainer.md
Normal 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
3
go.mod
@@ -6,11 +6,14 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
8
go.sum
8
go.sum
@@ -1,9 +1,12 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
@@ -15,6 +18,11 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
30
ops/README.md
Normal file
30
ops/README.md
Normal 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
3
ops/compile.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
go build -tags fts5 -o gas ./cmd
|
||||
25
ops/devcontainer/Dockerfile
Normal file
25
ops/devcontainer/Dockerfile
Normal 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
9
ops/devcontainer/build.sh
Executable 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
12
ops/devcontainer/start.sh
Executable 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
52
ops/gas_init_test.sh
Executable 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
48
ops/sqlite_lint_test.sh
Executable 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"
|
||||
376
pkg/codegen/modelgenerate/generate_model.go
Normal file
376
pkg/codegen/modelgenerate/generate_model.go
Normal 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")
|
||||
}
|
||||
277
pkg/codegen/modelgenerate/generate_testfile.go
Normal file
277
pkg/codegen/modelgenerate/generate_testfile.go
Normal 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
51
pkg/codegen/pkg.go
Normal 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
38
pkg/codegen/tpl/db.go.tpl
Normal 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
3
pkg/codegen/tpl/mount.sh
Normal 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
6
pkg/codegen/tpl/reset.sh
Normal 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
|
||||
10
pkg/codegen/tpl/schema.sql
Normal file
10
pkg/codegen/tpl/schema.sql
Normal 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);
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
// Tracks whether the DB connector has been initialized
|
||||
is_initialized bool
|
||||
type DBConfig struct {
|
||||
// // Tracks whether the DB connector has been initialized
|
||||
// is_initialized bool
|
||||
|
||||
// The SQL schema of the database under management
|
||||
sql_schema *string
|
||||
@@ -20,11 +20,7 @@ var (
|
||||
// Database starts at version 0. First migration brings us to version 1
|
||||
migrations *[]string
|
||||
version_number uint
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTargetExists = errors.New("target already exists")
|
||||
)
|
||||
}
|
||||
|
||||
// Colors for terminal output
|
||||
const (
|
||||
@@ -40,64 +36,82 @@ const (
|
||||
ColorWhite = "\033[97m"
|
||||
)
|
||||
|
||||
func Init(schema *string, migrationsList *[]string) {
|
||||
sql_schema = schema
|
||||
migrations = migrationsList
|
||||
version_number = uint(len(*migrations))
|
||||
is_initialized = true
|
||||
func Init(schema *string, migrationsList *[]string) DBConfig {
|
||||
return DBConfig{
|
||||
sql_schema: schema,
|
||||
migrations: migrationsList,
|
||||
version_number: uint(len(*migrationsList)),
|
||||
}
|
||||
}
|
||||
|
||||
func Create(path string) (*sqlx.DB, error) {
|
||||
func (c DBConfig) Create(path string) (*sqlx.DB, error) {
|
||||
// First check if the path already exists
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return nil, ErrTargetExists
|
||||
return nil, ErrDatabaseAlreadyExists
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("path error: %w", err)
|
||||
}
|
||||
|
||||
// Create DB file
|
||||
fmt.Printf("Creating............. %s\n", path)
|
||||
db := sqlx.MustOpen("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
|
||||
db.MustExec(*sql_schema)
|
||||
db, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening db: %w", err)
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("running pragma statements: %w", err)
|
||||
}
|
||||
if _, err = db.Exec(*c.sql_schema); err != nil {
|
||||
return nil, fmt.Errorf("creating schema: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Connect(path string) (*sqlx.DB, error) {
|
||||
db := sqlx.MustOpen("sqlite3", fmt.Sprintf("%s?_foreign_keys=on&_journal_mode=WAL", path))
|
||||
err := CheckAndUpdateVersion(db)
|
||||
func (c DBConfig) Connect(path string) (*sqlx.DB, error) {
|
||||
db, err := sqlx.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening db: %w", err)
|
||||
}
|
||||
if _, err = db.Exec("pragma foreign_keys=on; pragma journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("running pragma statements: %w", err)
|
||||
}
|
||||
|
||||
err = c.CheckAndUpdateVersion(db)
|
||||
return db, err
|
||||
}
|
||||
|
||||
func CheckAndUpdateVersion(db *sqlx.DB) error {
|
||||
func (c DBConfig) CheckAndUpdateVersion(db *sqlx.DB) error {
|
||||
var version uint
|
||||
err := db.Get(&version, "select version from db_version")
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't check database version: %w", err)
|
||||
}
|
||||
|
||||
if version > version_number {
|
||||
return VersionMismatchError{version_number, version}
|
||||
if version > c.version_number {
|
||||
return VersionMismatchError{c.version_number, version}
|
||||
}
|
||||
|
||||
if version_number > version {
|
||||
if c.version_number > version {
|
||||
fmt.Print(ColorYellow)
|
||||
fmt.Printf("================================================\n")
|
||||
fmt.Printf("Database version is out of date. Upgrading database from version %d to version %d!\n", version,
|
||||
version_number)
|
||||
c.version_number)
|
||||
fmt.Print(ColorReset)
|
||||
UpgradeFromXToY(db, version, version_number)
|
||||
c.UpgradeFromXToY(db, version, c.version_number)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run all the migrations from version X to version Y, and update the `database_version` table's `version_number`
|
||||
func UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
|
||||
// UpgradeFromXToY runs all the migrations from version X to version Y, and update the `database_version` table's `version_number`
|
||||
func (c DBConfig) UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
|
||||
for i := x; i < y; i++ {
|
||||
fmt.Print(ColorCyan)
|
||||
fmt.Println((*migrations)[i])
|
||||
fmt.Println((*c.migrations)[i])
|
||||
fmt.Print(ColorReset)
|
||||
|
||||
// Execute the migration in a transaction
|
||||
@@ -105,7 +119,7 @@ func UpgradeFromXToY(db *sqlx.DB, x uint, y uint) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
tx.MustExec((*migrations)[i])
|
||||
tx.MustExec((*c.migrations)[i])
|
||||
tx.MustExec("update db_version set version = ?", i+1)
|
||||
if err := tx.Commit(); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestCreateAndConnectToDB(t *testing.T) {
|
||||
schema_sql := string(_schema_sql)
|
||||
migrations := []string{}
|
||||
|
||||
db.Init(&schema_sql, &migrations)
|
||||
config := db.Init(&schema_sql, &migrations)
|
||||
|
||||
data_dir := "../../sample_data/data"
|
||||
_ = os.MkdirAll(data_dir, os.FileMode(0o644))
|
||||
@@ -28,9 +28,9 @@ func TestCreateAndConnectToDB(t *testing.T) {
|
||||
dbPath := filepath.Join(data_dir, "test.db")
|
||||
_ = os.Remove(dbPath) // Delete it if it exists
|
||||
|
||||
_, err = db.Create(dbPath)
|
||||
_, err = config.Create(dbPath)
|
||||
assert.NoError(err)
|
||||
_, err = db.Connect(dbPath)
|
||||
_, err = config.Connect(dbPath)
|
||||
assert.NoError(err)
|
||||
}
|
||||
|
||||
@@ -44,42 +44,42 @@ func TestVersionUpgrade(t *testing.T) {
|
||||
insert into db_version values(0);
|
||||
`
|
||||
migrations := []string{}
|
||||
db.Init(&initial_schema, &migrations)
|
||||
config := db.Init(&initial_schema, &migrations)
|
||||
|
||||
connection, err := db.Create(":memory:")
|
||||
connection, err := config.Create(":memory:")
|
||||
require.NoError(err)
|
||||
|
||||
get_version := func(c *sqlx.DB) (ret int) {
|
||||
// TODO: this should be a function exposed by the `db` package itself
|
||||
c.Get(&ret, "select version from db_version")
|
||||
require.NoError(c.Get(&ret, "select version from db_version"))
|
||||
return
|
||||
}
|
||||
|
||||
var items []int
|
||||
connection.Select(&items, "select * from items")
|
||||
require.NoError(connection.Select(&items, "select * from items"))
|
||||
require.Len(items, 0)
|
||||
require.Equal(0, get_version(connection))
|
||||
|
||||
// Create a migration to add a new Item
|
||||
migrations = append(migrations, "insert into items (rowid) values (1)")
|
||||
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
db.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
|
||||
var items2 []int
|
||||
connection.Select(&items2, "select * from items")
|
||||
require.NoError(connection.Select(&items2, "select * from items"))
|
||||
require.Len(items2, 1)
|
||||
require.Equal(1, get_version(connection))
|
||||
|
||||
// Create a migration to add a new Item
|
||||
migrations = append(migrations, `alter table items add column name string default 'asdf'`)
|
||||
db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
db.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
config = db.Init(&initial_schema, &migrations) // Reinitialize with the new migration
|
||||
config.UpgradeFromXToY(connection, uint(len(migrations)-1), uint(len(migrations)))
|
||||
|
||||
var items3 []struct {
|
||||
ID uint64 `db:"rowid"`
|
||||
Name string `db:"name"`
|
||||
}
|
||||
connection.Select(&items3, "select * from items")
|
||||
require.NoError(connection.Select(&items3, "select * from items"))
|
||||
require.Len(items2, 1)
|
||||
assert.Equal(t, "asdf", items3[0].Name)
|
||||
require.Equal(2, get_version(connection))
|
||||
|
||||
87
pkg/db/errors.go
Normal file
87
pkg/db/errors.go
Normal 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
64
pkg/db/timestamp.go
Normal 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
|
||||
}
|
||||
12
pkg/flowutils/flowutils.go
Normal file
12
pkg/flowutils/flowutils.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package flowutils
|
||||
|
||||
func PanicIf(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Must[T any](val T, err error) T {
|
||||
PanicIf(err)
|
||||
return val
|
||||
}
|
||||
207
pkg/schema/lint/checks.go
Normal file
207
pkg/schema/lint/checks.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package lint
|
||||
|
||||
import (
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
Name string
|
||||
Explanation string
|
||||
Execute func(schema.Schema) []CheckResult
|
||||
}
|
||||
|
||||
// CheckResult represents a row in the query result with error message, table name, and column name.
|
||||
type CheckResult struct {
|
||||
ErrorMsg string `db:"error_msg"`
|
||||
TableName string `db:"table_name"`
|
||||
ColumnName string `db:"column_name"`
|
||||
}
|
||||
|
||||
var Checks = []Check{
|
||||
{
|
||||
Name: "require_not_null",
|
||||
Explanation: "All columns should be marked as `not null` unless they are foreign keys. (Primary keys are\n" +
|
||||
"automatically not-null, and don't need to be specified.)",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
for tablename, tbl := range s.Tables {
|
||||
if tbl.TableType != "table" {
|
||||
continue
|
||||
}
|
||||
for _, column := range s.Tables[tablename].Columns {
|
||||
if !column.IsNotNull && !column.IsForeignKey && !column.IsPrimaryKey {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "Column should be \"not null\"",
|
||||
TableName: tablename,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "require_strict",
|
||||
Explanation: "All tables should be marked as `strict` (must specify column types; types must be int,\n" +
|
||||
"integer, real, text, blob or any). This disallows all 'date' and 'time' column types.\n" +
|
||||
"See more: https://www.sqlite.org/stricttables.html",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
for tablename, tbl := range s.Tables {
|
||||
if tbl.TableType != "table" {
|
||||
continue
|
||||
}
|
||||
if !s.Tables[tablename].IsStrict {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "Table should be marked \"strict\"",
|
||||
TableName: tablename,
|
||||
ColumnName: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "forbid_int_type",
|
||||
Explanation: "All columns should use `integer` type instead of `int`.",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
for tablename := range s.Tables {
|
||||
for _, column := range s.Tables[tablename].Columns {
|
||||
if column.Type == "int" {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "Column should use \"integer\" type instead of \"int\"",
|
||||
TableName: tablename,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "require_explicit_primary_key",
|
||||
Explanation: "All tables must have a primary key. If it's rowid, it has to be named explicitly.",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
tableloop:
|
||||
for tablename, tbl := range s.Tables {
|
||||
if tbl.TableType != "table" {
|
||||
continue
|
||||
}
|
||||
for _, column := range s.Tables[tablename].Columns {
|
||||
if column.IsPrimaryKey {
|
||||
continue tableloop
|
||||
}
|
||||
}
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "Table should declare an explicit primary key",
|
||||
TableName: tablename,
|
||||
ColumnName: "",
|
||||
})
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "require_indexes_for_foreign_keys",
|
||||
Explanation: "Columns referenced by foreign keys must have indexes.",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
for tablename := range s.Tables {
|
||||
fk_loop:
|
||||
for _, column := range s.Tables[tablename].Columns {
|
||||
if !column.IsForeignKey {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if target column is a primary key
|
||||
for _, target_col := range s.Tables[column.ForeignKeyTargetTable].Columns {
|
||||
if target_col.Name == column.ForeignKeyTargetColumn && target_col.IsPrimaryKey {
|
||||
continue fk_loop
|
||||
}
|
||||
}
|
||||
|
||||
// Check if target column is at the beginning of any index
|
||||
for _, idx := range s.Indexes {
|
||||
if idx.TableName != column.ForeignKeyTargetTable {
|
||||
continue
|
||||
}
|
||||
for _, idx_col_name := range idx.Columns {
|
||||
if column.ForeignKeyTargetColumn == idx_col_name {
|
||||
continue fk_loop
|
||||
}
|
||||
break // Only look at 1st column
|
||||
}
|
||||
}
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "Foreign keys should point to indexed columns",
|
||||
TableName: tablename,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "forbid_rowid_on_without_rowid_table",
|
||||
Explanation: "Tables that are `without rowid` may not have a `rowid` column",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
for tblName, tbl := range s.Tables {
|
||||
if !tbl.IsWithoutRowid {
|
||||
continue
|
||||
}
|
||||
for _, column := range tbl.Columns {
|
||||
if column.Name == "rowid" {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "rowid on 'without rowid' table",
|
||||
TableName: tblName,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "require_explicit_rowid",
|
||||
Explanation: "All tables should have an explicit `rowid integer primary key` column, unless they\n" +
|
||||
"are declared `without rowid`.",
|
||||
Execute: func(s schema.Schema) (ret []CheckResult) {
|
||||
tbl_loop:
|
||||
for tblName, tbl := range s.Tables {
|
||||
if tbl.TableType != "table" {
|
||||
continue
|
||||
}
|
||||
if tbl.IsWithoutRowid {
|
||||
continue
|
||||
}
|
||||
for _, column := range tbl.Columns {
|
||||
if column.Name == "rowid" {
|
||||
if !column.IsPrimaryKey {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "`rowid` column not declared \"primary key\"",
|
||||
TableName: tblName,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
if column.Type != "integer" {
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "non-integer `rowid` column",
|
||||
TableName: tblName,
|
||||
ColumnName: column.Name,
|
||||
})
|
||||
}
|
||||
continue tbl_loop
|
||||
}
|
||||
}
|
||||
ret = append(ret, CheckResult{
|
||||
ErrorMsg: "no `rowid` column",
|
||||
TableName: tblName,
|
||||
ColumnName: "",
|
||||
})
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
}
|
||||
75
pkg/schema/lint/checks_test.go
Normal file
75
pkg/schema/lint/checks_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package lint_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema/lint"
|
||||
)
|
||||
|
||||
func TestFailureCases(t *testing.T) {
|
||||
test_cases := []struct {
|
||||
sqlFile string
|
||||
expectedFailures []string
|
||||
}{
|
||||
{"test_schemas/failure-has-foreign-key-no-index.sql", []string{"require_indexes_for_foreign_keys"}},
|
||||
{"test_schemas/failure-has-ints.sql", []string{"forbid_int_type"}},
|
||||
{"test_schemas/failure-has-nulls.sql", []string{"require_not_null"}},
|
||||
{"test_schemas/failure-no-strict.sql", []string{"require_strict"}},
|
||||
{"test_schemas/failure-rowid-on-without-rowid.sql", []string{"forbid_rowid_on_without_rowid_table"}},
|
||||
{"test_schemas/failure-missing-rowid.sql", []string{"require_explicit_rowid"}},
|
||||
{"test_schemas/failure-non-integer-rowid.sql", []string{"require_explicit_rowid"}},
|
||||
{"test_schemas/failure-non-primary-key-rowid.sql", []string{"require_explicit_rowid"}},
|
||||
{"test_schemas/failure-total.sql", []string{
|
||||
"require_not_null",
|
||||
"require_explicit_primary_key",
|
||||
"forbid_int_type",
|
||||
"require_strict",
|
||||
"require_indexes_for_foreign_keys",
|
||||
"require_explicit_rowid",
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test_case := range test_cases {
|
||||
t.Run(test_case.sqlFile, func(t *testing.T) {
|
||||
schema, err := schema.SchemaFromSQLFile(test_case.sqlFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, check := range lint.Checks {
|
||||
results := check.Execute(schema)
|
||||
|
||||
is_failure := len(results) > 0
|
||||
is_failure_expected := slices.Contains(test_case.expectedFailures, check.Name)
|
||||
|
||||
if is_failure != is_failure_expected {
|
||||
if is_failure_expected {
|
||||
t.Errorf("Expected check '%s' to fail, but it passed: %s", check.Name, test_case.sqlFile)
|
||||
} else {
|
||||
t.Errorf("Expected check '%s' to pass, but it failed: %s (%q.%q)",
|
||||
check.Name,
|
||||
test_case.sqlFile,
|
||||
results[0].TableName, results[0].ColumnName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessCase(t *testing.T) {
|
||||
file := "test_schemas/success.sql"
|
||||
schema, err := schema.SchemaFromSQLFile(file)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, check := range lint.Checks {
|
||||
results := check.Execute(schema)
|
||||
for _, r := range results {
|
||||
t.Errorf("Unexpected error in file %q: %#v", file, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
create table stuff (
|
||||
rowid integer primary key,
|
||||
data text not null,
|
||||
amount integer not null
|
||||
) strict;
|
||||
|
||||
create table stuff2 (
|
||||
rowid integer primary key,
|
||||
label text not null unique,
|
||||
stuff_id integer references stuff(rowid),
|
||||
alternative_stuff_id integer references stuff(amount)
|
||||
) strict;
|
||||
13
pkg/schema/lint/test_schemas/failure-has-ints.sql
Normal file
13
pkg/schema/lint/test_schemas/failure-has-ints.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
create table stuff (
|
||||
rowid integer primary key,
|
||||
data text not null,
|
||||
amount integer not null
|
||||
) strict;
|
||||
create index index_stuff_amount on stuff (amount);
|
||||
|
||||
create table stuff2 (
|
||||
rowid integer primary key,
|
||||
label text not null unique,
|
||||
stuff_id int references stuff(rowid),
|
||||
alternative_stuff_id integer references stuff(amount)
|
||||
) strict;
|
||||
13
pkg/schema/lint/test_schemas/failure-has-nulls.sql
Normal file
13
pkg/schema/lint/test_schemas/failure-has-nulls.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
create table stuff (
|
||||
rowid integer primary key,
|
||||
data text not null,
|
||||
amount integer
|
||||
) strict;
|
||||
create index index_stuff_amount on stuff (amount);
|
||||
|
||||
create table stuff2 (
|
||||
rowid integer primary key,
|
||||
label text not null unique,
|
||||
stuff_id integer references stuff(rowid),
|
||||
alternative_stuff_id integer references stuff(amount)
|
||||
) strict;
|
||||
3
pkg/schema/lint/test_schemas/failure-missing-rowid.sql
Normal file
3
pkg/schema/lint/test_schemas/failure-missing-rowid.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
create table missing_rowid (
|
||||
a integer primary key
|
||||
) strict;
|
||||
13
pkg/schema/lint/test_schemas/failure-no-strict.sql
Normal file
13
pkg/schema/lint/test_schemas/failure-no-strict.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
create table stuff (
|
||||
rowid integer primary key,
|
||||
data text not null,
|
||||
amount integer not null
|
||||
) strict;
|
||||
create index index_stuff_amount on stuff (amount);
|
||||
|
||||
create table stuff2 (
|
||||
rowid integer primary key,
|
||||
label text not null unique,
|
||||
stuff_id integer references stuff(rowid),
|
||||
alternative_stuff_id integer references stuff(amount)
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
create table non_integer_rowid (
|
||||
rowid text primary key,
|
||||
a integer not null
|
||||
) strict;
|
||||
@@ -0,0 +1,4 @@
|
||||
create table withrowidbutnotprimarykey (
|
||||
rowid integer not null,
|
||||
a integer primary key
|
||||
) strict;
|
||||
@@ -0,0 +1,4 @@
|
||||
create table weirdrowid (
|
||||
rowid integer primary key,
|
||||
a integer not null
|
||||
) strict, without rowid;
|
||||
28
pkg/schema/lint/test_schemas/failure-total.sql
Normal file
28
pkg/schema/lint/test_schemas/failure-total.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
PRAGMA foreign_keys = on;
|
||||
|
||||
|
||||
create table implicit_rowid (
|
||||
a integer
|
||||
);
|
||||
|
||||
create table explicit_rowid_not_pk (
|
||||
rowid integer
|
||||
);
|
||||
|
||||
create table explicit_rowid (
|
||||
rowid integer primary key
|
||||
);
|
||||
|
||||
create table without_rowid (
|
||||
a integer primary key
|
||||
) without rowid;
|
||||
|
||||
create table multi_column (
|
||||
a int,
|
||||
b integer,
|
||||
primary key(a, b)
|
||||
);
|
||||
|
||||
create table foreign_key_missing_index (
|
||||
a references implicit_rowid(a)
|
||||
);
|
||||
26
pkg/schema/lint/test_schemas/success.sql
Normal file
26
pkg/schema/lint/test_schemas/success.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
create table stuff (
|
||||
rowid integer primary key,
|
||||
data text not null,
|
||||
amount integer not null
|
||||
) strict;
|
||||
create index index_stuff_amount on stuff (amount);
|
||||
|
||||
create virtual table stuff_fts using fts5(
|
||||
data,
|
||||
content='stuff',
|
||||
content_rowid='rowid',
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
|
||||
create table stuff2 (
|
||||
weird_pk integer primary key,
|
||||
label text not null unique,
|
||||
stuff_id integer references stuff(rowid),
|
||||
alternative_stuff_id integer references stuff(amount)
|
||||
) strict, without rowid;
|
||||
|
||||
create table stuff3 (
|
||||
rowid integer primary key,
|
||||
stuff2_id integer not null references stuff2(weird_pk)
|
||||
) strict;
|
||||
@@ -2,12 +2,15 @@ package schema
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jinzhu/inflection"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
. "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
|
||||
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
|
||||
)
|
||||
|
||||
@@ -22,26 +25,38 @@ func InitDB(sql_schema string) *sqlx.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func SchemaFromSQLFile(filepath string) (Schema, error) {
|
||||
sqlBytes, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return Schema{}, fmt.Errorf("failed to read SQL file: %w", err)
|
||||
}
|
||||
return SchemaFromDB(InitDB(string(sqlBytes))), nil
|
||||
}
|
||||
|
||||
// SchemaFromDB takes a DB connection, checks its schema metadata tables, and returns a Schema.
|
||||
func SchemaFromDB(db *sqlx.DB) Schema {
|
||||
ret := Schema{}
|
||||
ret := Schema{Tables: map[string]Table{}, Indexes: map[string]Index{}}
|
||||
|
||||
var tables []Table
|
||||
err := db.Select(&tables, `select name, is_strict, is_without_rowid from tables`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
PanicIf(db.Select(&tables, `select name, table_type, is_strict, is_without_rowid from tables`))
|
||||
for _, tbl := range tables {
|
||||
tbl.TypeName = textutils.SnakeToCamel(inflection.Singular(tbl.TableName))
|
||||
tbl.TypeIDName = tbl.TypeName + "ID"
|
||||
tbl.GoTypeName = TypenameFromTablename(tbl.TableName)
|
||||
tbl.TypeIDName = tbl.GoTypeName + "ID"
|
||||
tbl.VarName = strings.ToLower(string(tbl.TableName[0]))
|
||||
|
||||
err := db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ret[tbl.TableName] = tbl
|
||||
PanicIf(db.Select(&tbl.Columns, `select * from columns where table_name = ?`, tbl.TableName))
|
||||
ret.Tables[tbl.TableName] = tbl
|
||||
}
|
||||
|
||||
var indexes []Index
|
||||
PanicIf(db.Select(&indexes, `select index_name, table_name, is_unique from indexes`))
|
||||
for _, idx := range indexes {
|
||||
PanicIf(db.Select(&idx.Columns, `select column_name from index_columns where index_name = ? order by rank`, idx.Name))
|
||||
ret.Indexes[idx.Name] = idx
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func TypenameFromTablename(tablename string) string {
|
||||
return textutils.SnakeToCamel(inflection.Singular(tablename))
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ func TestParseSchema(t *testing.T) {
|
||||
schema := schema.SchemaFromDB(db)
|
||||
expected_tbls := []string{"food_types", "foods", "units", "ingredients", "recipes", "iterations", "db_version"}
|
||||
for _, tbl_name := range expected_tbls {
|
||||
_, is_ok := schema[tbl_name]
|
||||
_, is_ok := schema.Tables[tbl_name]
|
||||
assert.True(is_ok)
|
||||
}
|
||||
|
||||
foods := schema["foods"]
|
||||
foods := schema.Tables["foods"]
|
||||
assert.Equal(foods.TableName, "foods")
|
||||
assert.Equal(foods.TypeName, "Food")
|
||||
assert.Equal(foods.GoTypeName, "Food")
|
||||
assert.Equal(foods.TypeIDName, "FoodID")
|
||||
assert.Equal(foods.IsStrict, true)
|
||||
assert.Len(foods.Columns, 20)
|
||||
@@ -44,11 +44,36 @@ func TestParseSchema(t *testing.T) {
|
||||
assert.Equal(foods.Columns[16].HasDefaultValue, true)
|
||||
assert.Equal(foods.Columns[16].DefaultValue, "100")
|
||||
|
||||
ingredients := schema["ingredients"]
|
||||
ingredients := schema.Tables["ingredients"]
|
||||
assert.Equal(ingredients.Columns[0].Name, "rowid")
|
||||
assert.Equal(ingredients.Columns[0].IsPrimaryKey, true)
|
||||
assert.Equal(ingredients.Columns[1].Name, "food_id")
|
||||
assert.Equal(ingredients.Columns[1].IsForeignKey, true)
|
||||
assert.Equal(ingredients.Columns[1].ForeignKeyTargetTable, "foods")
|
||||
assert.Equal(ingredients.Columns[1].ForeignKeyTargetColumn, "rowid")
|
||||
|
||||
// -------
|
||||
// Indexes
|
||||
// -------
|
||||
|
||||
// Unique index
|
||||
units_name_idx, isOk := schema.Indexes["sqlite_autoindex_units_1"]
|
||||
require.True(t, isOk)
|
||||
assert.True(units_name_idx.IsUnique)
|
||||
assert.Equal("units", units_name_idx.TableName)
|
||||
assert.Equal([]string{"name"}, units_name_idx.Columns)
|
||||
|
||||
// Non-unique, declared index
|
||||
foods_protein_idx, isOk := schema.Indexes["foods_protein"]
|
||||
require.True(t, isOk)
|
||||
assert.False(foods_protein_idx.IsUnique)
|
||||
assert.Equal("foods", foods_protein_idx.TableName)
|
||||
assert.Equal([]string{"protein"}, foods_protein_idx.Columns)
|
||||
|
||||
// Multi-column index
|
||||
ingr_seq_idx, isOk := schema.Indexes["sqlite_autoindex_ingredients_1"]
|
||||
require.True(t, isOk)
|
||||
assert.True(ingr_seq_idx.IsUnique)
|
||||
assert.Equal("ingredients", ingr_seq_idx.TableName)
|
||||
assert.Equal([]string{"in_recipe_id", "list_order"}, ingr_seq_idx.Columns)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type Column struct {
|
||||
HasDefaultValue bool `db:"has_default_value"`
|
||||
DefaultValue string `db:"dflt_value"`
|
||||
IsPrimaryKey bool `db:"is_primary_key"`
|
||||
PrimaryKeyRank uint `db:"primary_key_rank"`
|
||||
IsForeignKey bool `db:"is_foreign_key"`
|
||||
ForeignKeyTargetTable string `db:"fk_target_table"`
|
||||
ForeignKeyTargetColumn string `db:"fk_target_column"`
|
||||
@@ -21,16 +22,34 @@ func (c Column) IsNullableForeignKey() bool {
|
||||
|
||||
// Table is a single SQLite table.
|
||||
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"`
|
||||
IsWithoutRowid bool `db:"is_without_rowid"`
|
||||
|
||||
Columns []Column
|
||||
|
||||
TypeIDName string
|
||||
VarName string
|
||||
TypeName string
|
||||
|
||||
// Default variable name for variables of this type to use when generating Go code
|
||||
VarName string
|
||||
|
||||
// Name of corresponding model type to be generated
|
||||
GoTypeName string
|
||||
}
|
||||
|
||||
// Schema is a container for a bunch of Tables, indexed by table name.
|
||||
type Schema map[string]Table
|
||||
type Index struct {
|
||||
Name string `db:"index_name"`
|
||||
TableName string `db:"table_name"`
|
||||
Columns []string
|
||||
IsUnique bool `db:"is_unique"`
|
||||
// TODO: `where ...` for partial indexes
|
||||
// TODO: identify columns that are expressions
|
||||
}
|
||||
|
||||
type Schema struct {
|
||||
Tables map[string]Table
|
||||
Indexes map[string]Index
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
create temporary view tables as
|
||||
select l.schema,
|
||||
l.name,
|
||||
l.type,
|
||||
l.type as table_type,
|
||||
l.wr as is_without_rowid,
|
||||
l.strict as is_strict
|
||||
from sqlite_schema s
|
||||
@@ -10,15 +10,31 @@ create temporary view tables as
|
||||
|
||||
create temporary view columns as
|
||||
select tables.name as table_name,
|
||||
table_info.name as column_name,
|
||||
lower(table_info.type) as column_type,
|
||||
"notnull",
|
||||
dflt_value is not null as has_default_value,
|
||||
ifnull(dflt_value, 0) dflt_value,
|
||||
pk as is_primary_key,
|
||||
fk."table" is not null as is_foreign_key,
|
||||
ifnull(fk."table", '') as fk_target_table,
|
||||
ifnull(fk."to", '') as fk_target_column
|
||||
from tables
|
||||
join pragma_table_info(tables.name) as table_info
|
||||
left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name;
|
||||
table_info.name as column_name,
|
||||
lower(table_info.type) as column_type,
|
||||
"notnull",
|
||||
dflt_value is not null as has_default_value,
|
||||
ifnull(dflt_value, 0) dflt_value,
|
||||
pk != 0 as is_primary_key,
|
||||
pk as primary_key_rank,
|
||||
fk."table" is not null as is_foreign_key,
|
||||
ifnull(fk."table", '') as fk_target_table,
|
||||
ifnull(fk."to", '') as fk_target_column
|
||||
from tables
|
||||
join pragma_table_info(tables.name) as table_info
|
||||
left join pragma_foreign_key_list(tables.name) as fk on fk."from" = column_name;
|
||||
|
||||
create temporary view indexes as
|
||||
select idx.name as index_name,
|
||||
tables.name as table_name,
|
||||
idx."unique" as is_unique
|
||||
from tables
|
||||
join pragma_index_list(tables.name) idx;
|
||||
|
||||
create temporary view index_columns as
|
||||
select indexes.index_name,
|
||||
idx_cols.cid as column_idx,
|
||||
ifnull(idx_cols.name, '') as column_name,
|
||||
idx_cols.seqno as rank
|
||||
from indexes
|
||||
join pragma_index_info(indexes.index_name) as idx_cols;
|
||||
|
||||
@@ -42,6 +42,8 @@ create table foods (rowid integer primary key,
|
||||
density real not null default 1,
|
||||
cook_ratio real not null default 1
|
||||
) strict;
|
||||
create index foods_protein on foods(protein);
|
||||
create index foods_fake_index on foods(protein + 1);
|
||||
|
||||
|
||||
create table units (rowid integer primary key,
|
||||
|
||||
Reference in New Issue
Block a user