Compare commits

10 Commits

Author SHA1 Message Date
01e37a0e07 TMP: codetables
Some checks failed
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 9s
2026-03-19 23:49:10 -07:00
f8664ed514 schema: split 'column', 'index' and 'schema' into separate files from 'table' 2026-03-19 23:46:59 -07:00
8c29d455ff codegen: fix defining the 'err' variable multiple times in foreign key checking test
All checks were successful
CI / build-docker (push) Successful in 12s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 41s
2026-03-19 15:43:31 -07:00
0f9a57dd85 codegen: fix more hardcoded "item" names in generated test file 2026-03-19 15:38:35 -07:00
11fed4b9c7 refactor: create CamelToPascal string helper 2026-03-19 15:35:18 -07:00
4e0836eb2e codegen: fix test generator hardcoding test string in multiple places
All checks were successful
CI / build-docker (push) Successful in 3s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 15s
2026-03-18 13:54:43 -07:00
f82929f6e2 style: remove unused fmtErrorf var
All checks were successful
CI / build-docker (push) Successful in 15s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 27s
2026-03-18 11:00:18 -07:00
1bc7f9111f codegen: implement "without rowid" tables
Some checks failed
CI / build-docker (push) Successful in 6s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Failing after 16s
2026-03-18 10:39:38 -07:00
c1150954e5 doc: update README
All checks were successful
CI / build-docker (push) Successful in 4s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 19s
2026-03-16 11:45:07 -07:00
fd90830340 doc: add README.md
All checks were successful
CI / build-docker (push) Successful in 11s
CI / build-docker-bootstrap (push) Has been skipped
CI / release-test (push) Successful in 3m33s
2026-03-16 11:38:36 -07:00
14 changed files with 596 additions and 244 deletions

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# GAS stack
## Compiling
Requires a Go compiler (minimum 1.22.5) and a C compiler, due to use of CGo.
```sh
git clone https://git.offline-twitter.com/offline-labs/gas-stack.git
cd gas-stack
go build -o gas -tags fts5 ./cmd
# Installation (optional)
sudo mv gas /usr/local/bin # ...or anywhere on your $PATH
which gas # should print "/usr/local/bin/gas"
```
## Using
The linter (`gas sqlite_lint`) is stable and useful.
The code generator is buggy, incomplete, and not remotely stable, but still quite useful. Don't expect it to produce perfectly working code, or even to compile correctly (e.g., you'll probably have to fix the imports). Copy-paste the parts that are useful, and delete the parts that aren't.
#### Linter
```sh
gas sqlite_lint <path/to/schema.sql>
```
#### Code generator
```sh
gas generate table_name # Generates a model
gas generate --test table_name # Optional: generates tests
```
It prints to the console. You can copy-paste the result. Or you can use bash redirection:
```sh
gas generate users > pkg/db/user.go
gas generate --test users > pkg/db/user_test.go
```
Useful flags:
- `--schema`: by default, `gas generate` assumes that the schema is at `pkg/db/schema.sql`. Use `gas generate --schema <path/to/schema.sql> [...]` to indicate otherwise

View File

@@ -22,6 +22,7 @@ func main() {
root_cmd.AddCommand(sqlite_lint) root_cmd.AddCommand(sqlite_lint)
root_cmd.AddCommand(cmd_init) root_cmd.AddCommand(cmd_init)
root_cmd.AddCommand(generate_model) root_cmd.AddCommand(generate_model)
root_cmd.AddCommand(generate_codetable_type)
if err := root_cmd.Execute(); err != nil { if err := root_cmd.Execute(); err != nil {
fmt.Println(RED + err.Error() + RESET) fmt.Println(RED + err.Error() + RESET)
os.Exit(1) os.Exit(1)

View File

@@ -0,0 +1,55 @@
package main
import (
"fmt"
"go/ast"
"os"
"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"
"github.com/spf13/cobra"
)
var generate_codetable_type = &cobra.Command{
Use: "generate_codetable <table_name>",
Short: "Generate a code-table enum type",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := Must(cmd.Flags().GetString("schema"))
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading path %s: %w", path, err)
}
db := schema.InitDB(string(sql))
schema := schema.SchemaFromDB(db)
table, isOk := schema.Tables[args[0]]
if !isOk {
return ErrNoSuchTable
}
vals := table.GetCodeTableValues(db)
decls := []ast.Decl{
modelgenerate.GenerateCodetableType(table),
modelgenerate.GenerateCodetableEnum(table, vals),
modelgenerate.GenerateCodetableStringerFunc(table, vals),
}
file := &ast.File{
Name: ast.NewIdent("db"), // TODO: parameterize
Decls: decls,
}
PanicIf(modelgenerate.FprintWithComments(os.Stdout, file))
return nil
},
}
// DUPE: generate-flags
func init() {
generate_codetable_type.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file")
generate_codetable_type.Flags().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")
generate_codetable_type.Flags().Bool("test", false, "Generate test file instead of regular file")
}

View File

@@ -46,7 +46,6 @@ var generate_model = &cobra.Command{
Specs: []ast.Spec{ Specs: []ast.Spec{
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"database/sql"`}}, &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: `"errors"`}},
&ast.ImportSpec{Path: &ast.BasicLit{Kind: token.STRING, Value: `"fmt"`}},
&ast.ImportSpec{ &ast.ImportSpec{
Name: ast.NewIdent("."), Name: ast.NewIdent("."),
Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`}, Path: &ast.BasicLit{Kind: token.STRING, Value: `"git.offline-twitter.com/offline-labs/gas-stack/pkg/db"`},
@@ -69,8 +68,16 @@ var generate_model = &cobra.Command{
modelgenerate.GenerateSQLFieldsConst(table), modelgenerate.GenerateSQLFieldsConst(table),
modelgenerate.GenerateSaveItemFunc(table), modelgenerate.GenerateSaveItemFunc(table),
modelgenerate.GenerateDeleteItemFunc(table), modelgenerate.GenerateDeleteItemFunc(table),
)
if table.IsWithoutRowid {
decls = append(decls,
modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()),
)
} else {
decls = append(decls,
modelgenerate.GenerateGetItemByIDFunc(table), modelgenerate.GenerateGetItemByIDFunc(table),
) )
}
for _, index := range schema.Indexes { for _, index := range schema.Indexes {
if index.TableName != table.TableName { if index.TableName != table.TableName {
// Skip indexes on other tables // Skip indexes on other tables
@@ -94,6 +101,7 @@ var generate_model = &cobra.Command{
}, },
} }
// DUPE: generate-flags
func init() { func init() {
generate_model.Flags().String("schema", "pkg/db/schema.sql", "Path to SQL schema file") 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().String("modname", "mymodule", "Name of project's Go module (TODO: detect automatically)")

View File

@@ -46,11 +46,19 @@ create table items (
created_at integer not null, created_at integer not null,
updated_at integer not null updated_at integer not null
) strict; ) strict;
create table item_to_item (
item1_id integer references items(rowid),
item2_id integer references items(rowid),
primary key (item1_id, item2_id)
) strict, without rowid;
EOF EOF
# Generate an item model and test file # Generate an item model and test file
$gas generate items > pkg/db/item.go $gas generate items > pkg/db/item.go
$gas generate items --test > pkg/db/item_test.go $gas generate items --test > pkg/db/item_test.go
$gas generate item_to_item > pkg/db/item_to_item.go
go mod tidy go mod tidy
# Run the tests # Run the tests

View File

@@ -0,0 +1,111 @@
package modelgenerate
import (
"fmt"
"go/ast"
"go/token"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
func GenerateCodetableType(table schema.Table) *ast.GenDecl {
return &ast.GenDecl{
Tok: token.TYPE,
Specs: []ast.Spec{&ast.TypeSpec{Name: &ast.Ident{Name: table.GoTypeName}, Type: &ast.Ident{Name: "int"}}},
}
}
func GenerateCodetableEnum(table schema.Table, vals []string) *ast.GenDecl {
getConstName := func(s string) string {
return table.GoTypeName + textutils.KebabToPascal(s)
}
constSpecs := []ast.Spec{}
for i, val := range vals {
spec := &ast.ValueSpec{
Names: []*ast.Ident{{Name: getConstName(val)}},
}
// Only the first one needs `iota`
if i == 0 {
spec.Type = &ast.Ident{Name: table.GoTypeName}
spec.Values = []ast.Expr{&ast.BinaryExpr{
X: &ast.Ident{Name: "iota"},
Op: token.ADD,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
}}
}
constSpecs = append(constSpecs, spec)
}
return &ast.GenDecl{Tok: token.CONST, Specs: constSpecs}
}
// GenerateCodetableStringerFunc implements the `Stringer` interface by defining a `String() string` function.
func GenerateCodetableStringerFunc(table schema.Table, vals []string) *ast.FuncDecl {
objIdent := ast.NewIdent(table.VarName)
return &ast.FuncDecl{
Recv: &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{objIdent}, Type: &ast.Ident{Name: table.GoTypeName}}}},
Name: &ast.Ident{Name: "String"},
Type: &ast.FuncType{Params: &ast.FieldList{}, Results: &ast.FieldList{List: []*ast.Field{{Type: &ast.Ident{Name: "string"}}}}},
Body: &ast.BlockStmt{
List: []ast.Stmt{
// names := []string{ ... }
&ast.AssignStmt{
Lhs: []ast.Expr{&ast.Ident{Name: "names"}},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CompositeLit{
Type: &ast.ArrayType{Elt: &ast.Ident{Name: "string"}},
Elts: func() []ast.Expr {
ret := []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `""`},
}
for _, val := range vals {
ret = append(ret, &ast.BasicLit{Kind: token.STRING, Value: fmt.Sprintf("%q", val)})
}
return ret
}(),
},
},
},
// if int(c) < 1 || int(c) >= len(names) { return "invalid" }
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: &ast.BinaryExpr{
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
Op: token.LSS,
Y: &ast.BasicLit{Kind: token.INT, Value: "1"},
},
Op: token.LOR,
Y: &ast.BinaryExpr{
X: &ast.CallExpr{Fun: &ast.Ident{Name: "int"}, Args: []ast.Expr{objIdent}},
Op: token.GEQ,
Y: &ast.CallExpr{Fun: &ast.Ident{Name: "len"}, Args: []ast.Expr{&ast.Ident{Name: "names"}}},
},
},
Body: &ast.BlockStmt{
List: []ast.Stmt{
&ast.ReturnStmt{Results: []ast.Expr{
&ast.CallExpr{Fun: &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Sprintf")}, Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: `"<%d=invalid>"`},
objIdent,
}},
}},
},
},
},
// return names[c]
&ast.ReturnStmt{
Results: []ast.Expr{
&ast.IndexExpr{
X: &ast.Ident{Name: "names"},
Index: objIdent,
},
},
},
},
},
}
}

View File

@@ -20,7 +20,6 @@ import (
var ( var (
dbRecv = &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}} dbRecv = &ast.FieldList{List: []*ast.Field{{Names: []*ast.Ident{ast.NewIdent("db")}, Type: ast.NewIdent("DB")}}}
dbDB = &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("DB")} dbDB = &ast.SelectorExpr{X: ast.NewIdent("db"), Sel: ast.NewIdent("DB")}
fmtErrorf = &ast.SelectorExpr{X: ast.NewIdent("fmt"), Sel: ast.NewIdent("Errorf")}
) )
func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident { func SQLFieldsConstIdent(tbl schema.Table) *ast.Ident {
@@ -54,6 +53,22 @@ func GoTypeForColumn(c schema.Column) ast.Expr {
} }
} }
func PanicIfRowsAffected(tbl schema.Table) *ast.IfStmt {
return &ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&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.NewIdent(tbl.VarName)}}},
}},
}
}
// --------------- // ---------------
// Generators // Generators
// --------------- // ---------------
@@ -255,10 +270,29 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
if col.Name == "created_at" && hasCreatedAt { if col.Name == "created_at" && hasCreatedAt {
continue continue
} }
if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables)
updatePairs = append(updatePairs, col.Name+"="+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 ")) 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 "),
)
upsertStmt := fmt.Sprintf("\n\t insert into %s (%s)\n\t values (%s)\n\t",
tbl.TableName,
strings.Join(insertCols, ", "),
strings.Join(insertVals, ", "),
)
if len(updatePairs) == 0 {
upsertStmt = upsertStmt + " on conflict do nothing\n\t"
} else {
upsertStmt = upsertStmt + fmt.Sprintf(" on conflict do update\n\t set %s\n\t", strings.Join(updatePairs, ",\n\t "))
}
checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl) checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl)
@@ -276,43 +310,25 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}}, Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
}) })
} }
// if item.ID == 0 {...} else {...}
ret = append(ret, &ast.IfStmt{ namedExecStmt := func(stmt string) []ast.Stmt {
Cond: &ast.BinaryExpr{ queryStmt := &ast.CallExpr{
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{
// Do create
List: append(
func() []ast.Stmt {
ret1 := []ast.Stmt{Comment("Do create")}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
namedExecStmt := &ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")}, Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{ Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"}, &ast.BasicLit{Kind: token.STRING, Value: "`" + stmt + "`"},
ast.NewIdent(tbl.VarName), ast.NewIdent(tbl.VarName),
}, },
} }
if !hasFks { if !hasFks {
// No foreign key checking needed; just use `Must` for brevity // No foreign key checking needed; just use `Must` for brevity
return append(ret1, &ast.AssignStmt{ return []ast.Stmt{&ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("result")}, Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(namedExecStmt)}, Rhs: []ast.Expr{mustCall(queryStmt)},
}) }}
} }
// There's foreign keys
return append(ret1, return []ast.Stmt{
// result, err := db.DB.NamedExec(`...`, u) // result, err := db.DB.NamedExec(`...`, u)
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{ Lhs: []ast.Expr{
@@ -320,9 +336,8 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
ast.NewIdent("err"), ast.NewIdent("err"),
}, },
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{namedExecStmt}, Rhs: []ast.Expr{queryStmt},
}, },
// if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) } // if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) }
&ast.IfStmt{ &ast.IfStmt{
Init: &ast.AssignStmt{ Init: &ast.AssignStmt{
@@ -367,7 +382,34 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
} }
}(), }(),
}, },
) }
}
if tbl.IsWithoutRowid {
ret = append(ret, namedExecStmt(upsertStmt)...)
ret = append(ret, PanicIfRowsAffected(tbl))
} else {
// if item.ID == 0 {...} else {...}
ret = append(ret, &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{
// Do create
List: append(
func() []ast.Stmt {
ret1 := []ast.Stmt{Comment("Do create")}
if hasCreatedAt {
// Auto-timestamps: created_at
ret1 = append(ret1, &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("CreatedAt")}},
Tok: token.ASSIGN,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}},
})
}
return append(ret1, namedExecStmt(insertStmt)...)
}(), }(),
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}}, Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}},
@@ -384,31 +426,16 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl {
}, },
Else: &ast.BlockStmt{ Else: &ast.BlockStmt{
// Do update // Do update
List: []ast.Stmt{ List: append(
Comment("Do update"), []ast.Stmt{Comment("Do update")},
&ast.AssignStmt{ append(
Lhs: []ast.Expr{ast.NewIdent("result")}, namedExecStmt(updateStmt),
Tok: token.DEFINE, PanicIfRowsAffected(tbl),
Rhs: []ast.Expr{mustCall(&ast.CallExpr{ )...,
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")}, ),
Args: []ast.Expr{&ast.BasicLit{Kind: token.STRING, Value: "`" + updateStmt + "`"}, ast.NewIdent(tbl.VarName)},
})},
},
&ast.IfStmt{
Cond: &ast.BinaryExpr{
X: mustCall(&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: fmtErrorf, 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")}}}}}}}},
},
},
}, },
}) })
}
if hasFks { if hasFks {
// If there's foreign key checking, it needs to return an error (or nil) // If there's foreign key checking, it needs to return an error (or nil)
ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}}) ret = append(ret, &ast.ReturnStmt{Results: []ast.Expr{ast.NewIdent("nil")}})
@@ -442,6 +469,61 @@ func getByIDFuncName(tblname string) string {
return "Get" + schema.TypenameFromTablename(tblname) + "ByID" return "Get" + schema.TypenameFromTablename(tblname) + "ByID"
} }
func GenerateGetItemBy(tbl schema.Table, cols []schema.Column) *ast.FuncDecl {
colNames := []string{}
funcNameSuffix := []string{}
funcParams := &ast.FieldList{List: []*ast.Field{}}
sqlParams := []ast.Expr{}
for _, col := range cols {
funcParam := ast.NewIdent(col.LongGoVarName())
funcParams.List = append(funcParams.List, &ast.Field{Names: []*ast.Ident{funcParam}, Type: GoTypeForColumn(col)})
colNames = append(colNames, fmt.Sprintf("%s = :%s", col.Name, col.Name))
funcNameSuffix = append(funcNameSuffix, col.GoFieldName())
sqlParams = append(sqlParams, funcParam)
}
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: fmt.Sprintf("`\n\t from %s\n\t where %s = ?\n\t`", tbl.TableName, strings.Join(colNames, " and "))},
}
return &ast.FuncDecl{
Recv: dbRecv,
Name: ast.NewIdent(fmt.Sprintf("Get%sBy%s", schema.TypenameFromTablename(tbl.TableName), strings.Join(funcNameSuffix, "And"))),
Type: &ast.FuncType{
Params: funcParams,
Results: &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")},
}},
},
Body: &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: dbDB, Sel: ast.NewIdent("Get")},
Args: append([]ast.Expr{&ast.UnaryExpr{Op: token.AND, X: ast.NewIdent("ret")}, selectExpr}, sqlParams...),
}},
},
&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{},
},
},
}
}
// GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function. // GenerateGetItemByIDFunc produces an AST for the `GetXyzByID()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function. // E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function.
func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl { func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
@@ -474,13 +556,12 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl {
}, },
} }
funcDecl := &ast.FuncDecl{ return &ast.FuncDecl{
Recv: dbRecv, Recv: dbRecv,
Name: ast.NewIdent(getByIDFuncName(tbl.TableName)), Name: ast.NewIdent(getByIDFuncName(tbl.TableName)),
Type: &ast.FuncType{Params: arg, Results: result}, Type: &ast.FuncType{Params: arg, Results: result},
Body: funcBody, Body: funcBody,
} }
return funcDecl
} }
// GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function. // GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function.
@@ -581,10 +662,12 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl {
// GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function. // GenerateDeleteItemFunc produces an AST for the `DeleteXyz()` function.
// E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function. // E.g., a table with `table.TypeName = "foods"` will produce a "DeleteFood()" function.
func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl { func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
arg := &ast.FieldList{List: []*ast.Field{{ colNames := []string{}
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, for _, c := range tbl.PrimaryKeyColumns() {
Type: ast.NewIdent(tbl.GoTypeName), colNames = append(colNames, fmt.Sprintf("%s = :%s", c.Name, c.Name))
}}} }
sqlStr := "`delete from " + tbl.TableName + fmt.Sprintf(" where %s`", strings.Join(colNames, " and "))
funcBody := &ast.BlockStmt{ funcBody := &ast.BlockStmt{
List: []ast.Stmt{ List: []ast.Stmt{
@@ -592,41 +675,24 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl {
Lhs: []ast.Expr{ast.NewIdent("result")}, Lhs: []ast.Expr{ast.NewIdent("result")},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{mustCall(&ast.CallExpr{ Rhs: []ast.Expr{mustCall(&ast.CallExpr{
Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("Exec")}, Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")},
Args: []ast.Expr{ Args: []ast.Expr{
&ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"}, &ast.BasicLit{Kind: token.STRING, Value: sqlStr},
&ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}, ast.NewIdent(tbl.VarName),
}, },
})}, })},
}, },
&ast.IfStmt{ PanicIfRowsAffected(tbl),
Cond: &ast.BinaryExpr{
X: mustCall(
&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: fmtErrorf,
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{ funcDecl := &ast.FuncDecl{
Recv: dbRecv, Recv: dbRecv,
Name: ast.NewIdent("Delete" + tbl.GoTypeName), Name: ast.NewIdent("Delete" + tbl.GoTypeName),
Type: &ast.FuncType{Params: arg, Results: nil}, Type: &ast.FuncType{Params: &ast.FieldList{List: []*ast.Field{{
Names: []*ast.Ident{ast.NewIdent(tbl.VarName)},
Type: ast.NewIdent(tbl.GoTypeName),
}}}, Results: nil},
Body: funcBody, Body: funcBody,
} }
return funcDecl return funcDecl

View File

@@ -8,6 +8,7 @@ import (
"github.com/jinzhu/inflection" "github.com/jinzhu/inflection"
pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema" pkgschema "git.offline-twitter.com/offline-labs/gas-stack/pkg/schema"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
) )
// GenerateModelTestAST produces an AST for a starter test file for a given model. // GenerateModelTestAST produces an AST for a starter test file for a given model.
@@ -15,9 +16,11 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
packageName := "db" packageName := "db"
testpackageName := packageName + "_test" testpackageName := packageName + "_test"
makeHelperName := ast.NewIdent("Make" + tbl.GoTypeName)
// func MakeItem() Item { return Item{} } // func MakeItem() Item { return Item{} }
makeItemFunc := &ast.FuncDecl{ makeItemFunc := &ast.FuncDecl{
Name: ast.NewIdent("Make" + tbl.GoTypeName), Name: makeHelperName,
Type: &ast.FuncType{ Type: &ast.FuncType{
Params: &ast.FieldList{}, Params: &ast.FieldList{},
Results: &ast.FieldList{ Results: &ast.FieldList{
@@ -57,8 +60,8 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
} }
testObj := ast.NewIdent("item") testObj := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName))
testObj2 := ast.NewIdent("item2") testObj2 := ast.NewIdent(textutils.CamelToPascal(tbl.GoTypeName) + "2")
fieldName := ast.NewIdent("Description") // TODO fieldName := ast.NewIdent("Description") // TODO
description1 := `"an item"` description1 := `"an item"`
description2 := `"a big item"` description2 := `"a big item"`
@@ -94,13 +97,13 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{testObj}, Lhs: []ast.Expr{testObj},
Tok: token.DEFINE, Tok: token.DEFINE,
Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("MakeItem"), Args: nil}}, Rhs: []ast.Expr{&ast.CallExpr{Fun: makeHelperName, Args: nil}},
}, },
// item.Description = "an item" // item.Description = "an item"
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{ Lhs: []ast.Expr{
&ast.SelectorExpr{ &ast.SelectorExpr{
X: ast.NewIdent("item"), X: testObj,
Sel: ast.NewIdent("Description"), Sel: ast.NewIdent("Description"),
}, },
}, },
@@ -108,7 +111,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
Rhs: []ast.Expr{ Rhs: []ast.Expr{
&ast.BasicLit{ &ast.BasicLit{
Kind: token.STRING, Kind: token.STRING,
Value: `"an item"`, Value: fmt.Sprintf("%q", description1),
}, },
}, },
}, },
@@ -148,12 +151,12 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
})}, })},
}, },
// assert.Equal(t, "an item", item2.Description) // assert.Equal(t, item.Description, item2.Description)
&ast.ExprStmt{X: &ast.CallExpr{ &ast.ExprStmt{X: &ast.CallExpr{
Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Equal")}, Fun: &ast.SelectorExpr{X: ast.NewIdent("assert"), Sel: ast.NewIdent("Equal")},
Args: []ast.Expr{ Args: []ast.Expr{
ast.NewIdent("t"), ast.NewIdent("t"),
&ast.BasicLit{Kind: token.STRING, Value: description1}, &ast.SelectorExpr{X: testObj, Sel: fieldName},
&ast.SelectorExpr{X: testObj2, Sel: fieldName}, &ast.SelectorExpr{X: testObj2, Sel: fieldName},
}, },
}}, }},
@@ -307,11 +310,11 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
}, },
} }
shouldDefineErr := true
for _, col := range tbl.Columns { for _, col := range tbl.Columns {
if col.IsForeignKey { if col.IsForeignKey {
shouldIncludeTestFkCheck = true shouldIncludeTestFkCheck = true
stmts = append(stmts, []ast.Stmt{ stmts = append(stmts, []ast.Stmt{
// post.QuotedPostID = 94354538969386985 // post.QuotedPostID = 94354538969386985
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{ Lhs: []ast.Expr{
@@ -332,7 +335,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
// err := db.SavePost(&post) // err := db.SavePost(&post)
&ast.AssignStmt{ &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("err")}, Lhs: []ast.Expr{ast.NewIdent("err")},
Tok: token.DEFINE, Tok: map[bool]token.Token{true: token.DEFINE, false: token.ASSIGN}[shouldDefineErr],
Rhs: []ast.Expr{ Rhs: []ast.Expr{
&ast.CallExpr{ &ast.CallExpr{
Fun: &ast.SelectorExpr{ Fun: &ast.SelectorExpr{
@@ -368,6 +371,7 @@ func GenerateModelTestAST(tbl pkgschema.Table, schema pkgschema.Schema, gomodNam
}, },
}, },
}...) }...)
shouldDefineErr = false
} }
} }
return stmts return stmts

67
pkg/schema/column.go Normal file
View File

@@ -0,0 +1,67 @@
package schema
import (
"strings"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils"
)
// Column represents a single column in a table.
type Column struct {
// TableName is the name of the SQLite table this column belongs to.
TableName string `db:"table_name"`
// Name is the SQLite column name.
Name string `db:"column_name"`
// Type is the SQLite type this column contains.
Type string `db:"column_type"`
IsNotNull bool `db:"notnull"`
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"`
}
// IsNullableForeignKey is a helper function.
func (c Column) IsNullableForeignKey() bool {
return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey
}
func (c Column) IsNonCodeTableForeignKey() bool {
return c.IsForeignKey && strings.HasSuffix(c.Name, "_id")
}
func (c Column) GoFieldName() string {
if c.Name == "rowid" {
return "ID"
}
if c.IsNonCodeTableForeignKey() {
return textutils.SnakeToCamel(strings.TrimSuffix(c.Name, "_id")) + "ID"
}
return textutils.SnakeToCamel(c.Name)
}
// GoVarName returns the name of a local variable for this column, e.g., when used as a function parameter.
func (c Column) GoVarName() string {
if c.Name == "rowid" {
return strings.ToLower(c.TableName)[0:1] + "ID"
// TODO: Or should it just be "id"??
}
// For foreign keys, use first letter of the target type and "ID". "UserID" => "uID"
if c.IsNonCodeTableForeignKey() {
return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID"
}
// Otherwise, just use the whole name
return c.LongGoVarName()
}
// LongGoVarName returns a lowercased version of the field name (Pascal => Camel).
func (c Column) LongGoVarName() string {
return textutils.CamelToPascal(c.GoFieldName())
}

10
pkg/schema/index.go Normal file
View File

@@ -0,0 +1,10 @@
package schema
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
}

6
pkg/schema/schema.go Normal file
View File

@@ -0,0 +1,6 @@
package schema
type Schema struct {
Tables map[string]Table
Indexes map[string]Index
}

View File

@@ -1,62 +1,14 @@
package schema package schema
import ( import (
"fmt"
"slices"
"sort" "sort"
"strings"
"git.offline-twitter.com/offline-labs/gas-stack/pkg/textutils" "git.offline-twitter.com/offline-labs/gas-stack/pkg/flowutils"
"github.com/jmoiron/sqlx"
) )
// Column represents a single column in a table.
type Column struct {
TableName string `db:"table_name"`
Name string `db:"column_name"`
Type string `db:"column_type"`
IsNotNull bool `db:"notnull"`
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"`
}
// IsNullableForeignKey is a helper function.
func (c Column) IsNullableForeignKey() bool {
return !c.IsNotNull && !c.IsPrimaryKey && c.IsForeignKey
}
func (c Column) IsNonCodeTableForeignKey() bool {
return c.IsForeignKey && strings.HasSuffix(c.Name, "_id")
}
func (c Column) GoFieldName() string {
if c.Name == "rowid" {
return "ID"
}
if c.IsNonCodeTableForeignKey() {
return textutils.SnakeToCamel(strings.TrimSuffix(c.Name, "_id")) + "ID"
}
return textutils.SnakeToCamel(c.Name)
}
// GoVarName returns the name of a local variable for this column, e.g., when used as a function parameter.
func (c Column) GoVarName() string {
if c.Name == "rowid" {
return strings.ToLower(c.TableName)[0:1] + "ID"
// TODO: Or should it just be "id"??
}
// For foreign keys, use first letter of the target type and "ID". "UserID" => "uID"
if c.IsNonCodeTableForeignKey() {
return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID"
}
// Otherwise, just lowercase the field name
fieldname := c.GoFieldName()
return strings.ToLower(fieldname)[0:1] + fieldname[1:]
}
// Table is a single SQLite table. // Table is a single SQLite table.
type Table struct { type Table struct {
TableName string `db:"name"` TableName string `db:"name"`
@@ -115,16 +67,10 @@ func (t Table) HasAutoTimestamps() (hasCreatedAt bool, hasUpdatedAt bool) {
return return
} }
type Index struct { func (t Table) GetCodeTableValues(db *sqlx.DB) (ret []string) {
Name string `db:"index_name"` if !slices.ContainsFunc(t.Columns, func(c Column) bool { return c.Name == "name" }) {
TableName string `db:"table_name"` panic("not a code table")
Columns []string
IsUnique bool `db:"is_unique"`
// TODO: `where ...` for partial indexes
// TODO: identify columns that are expressions
} }
flowutils.PanicIf(db.Select(&ret, fmt.Sprintf("select name from %s", t.TableName)))
type Schema struct { return
Tables map[string]Table
Indexes map[string]Index
} }

View File

@@ -9,3 +9,17 @@ func SnakeToCamel(s string) string {
} }
return strings.Join(parts, "") return strings.Join(parts, "")
} }
func KebabToPascal(s string) string {
parts := strings.Split(s, "-")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
return strings.Join(parts, "")
}
func CamelToPascal(s string) string {
return strings.ToLower(s)[0:1] + s[1:]
}

View File

@@ -0,0 +1,8 @@
create table item_types (
rowid integer primary key,
name text not null unique
) strict;
insert into item_types(rowid, name) values
(1, 'first-type'),
(2, 'second-type'),
(3, 'third-type');