From 1bc7f9111f4a37d3adc745b4e4b977940ad142ee Mon Sep 17 00:00:00 2001 From: ~wispem-wantex Date: Sat, 14 Feb 2026 18:33:41 -0800 Subject: [PATCH] codegen: implement "without rowid" tables --- cmd/subcmd_generate_models.go | 11 +- ops/gas_init_test.sh | 8 + pkg/codegen/modelgenerate/generate_model.go | 397 ++++++++++++-------- pkg/schema/table.go | 7 +- 4 files changed, 255 insertions(+), 168 deletions(-) diff --git a/cmd/subcmd_generate_models.go b/cmd/subcmd_generate_models.go index b7a1bf6..f11dcba 100644 --- a/cmd/subcmd_generate_models.go +++ b/cmd/subcmd_generate_models.go @@ -46,7 +46,6 @@ var generate_model = &cobra.Command{ 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"`}, @@ -69,8 +68,16 @@ var generate_model = &cobra.Command{ modelgenerate.GenerateSQLFieldsConst(table), modelgenerate.GenerateSaveItemFunc(table), modelgenerate.GenerateDeleteItemFunc(table), - modelgenerate.GenerateGetItemByIDFunc(table), ) + if table.IsWithoutRowid { + decls = append(decls, + modelgenerate.GenerateGetItemBy(table, table.PrimaryKeyColumns()), + ) + } else { + decls = append(decls, + modelgenerate.GenerateGetItemByIDFunc(table), + ) + } for _, index := range schema.Indexes { if index.TableName != table.TableName { // Skip indexes on other tables diff --git a/ops/gas_init_test.sh b/ops/gas_init_test.sh index 205138d..3891c18 100755 --- a/ops/gas_init_test.sh +++ b/ops/gas_init_test.sh @@ -46,11 +46,19 @@ create table items ( created_at integer not null, updated_at integer not null ) 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 # Generate an item model and test file $gas generate items > pkg/db/item.go $gas generate items --test > pkg/db/item_test.go +$gas generate item_to_item > pkg/db/item_to_item.go go mod tidy # Run the tests diff --git a/pkg/codegen/modelgenerate/generate_model.go b/pkg/codegen/modelgenerate/generate_model.go index 8dfec81..bf0e53d 100644 --- a/pkg/codegen/modelgenerate/generate_model.go +++ b/pkg/codegen/modelgenerate/generate_model.go @@ -54,6 +54,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 // --------------- @@ -255,10 +271,29 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl { if col.Name == "created_at" && hasCreatedAt { continue } - updatePairs = append(updatePairs, col.Name+"="+val) + if !col.IsPrimaryKey { // Don't try to update primary key columns (mainly for w/o rowid tables) + updatePairs = append(updatePairs, col.Name+"="+val) + } + } + insertStmt := fmt.Sprintf("\n\t\t insert into %s (%s)\n\t\t values (%s)\n\t\t", + 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 ")) } - 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 ")) checkForeignKeyFailuresAssignment, hasFks := buildFKCheckLambda(tbl) @@ -276,139 +311,132 @@ func GenerateSaveItemFunc(tbl schema.Table) *ast.FuncDecl { Rhs: []ast.Expr{&ast.CallExpr{Fun: ast.NewIdent("TimestampNow"), Args: []ast.Expr{}}}, }) } - // 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{}}}, - }) - } - namedExecStmt := &ast.CallExpr{ - Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")}, - Args: []ast.Expr{ - &ast.BasicLit{Kind: token.STRING, Value: "`" + insertStmt + "`"}, - ast.NewIdent(tbl.VarName), - }, - } - if !hasFks { - // No foreign key checking needed; just use `Must` for brevity - return append(ret1, &ast.AssignStmt{ - Lhs: []ast.Expr{ast.NewIdent("result")}, - Tok: token.DEFINE, - Rhs: []ast.Expr{mustCall(namedExecStmt)}, - }) - } - return append(ret1, - // result, err := db.DB.NamedExec(`...`, u) - &ast.AssignStmt{ - Lhs: []ast.Expr{ - ast.NewIdent("result"), - ast.NewIdent("err"), - }, - Tok: token.DEFINE, - Rhs: []ast.Expr{namedExecStmt}, - }, - - // if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) } - &ast.IfStmt{ - Init: &ast.AssignStmt{ - Lhs: []ast.Expr{ast.NewIdent("fkErr")}, - Tok: token.DEFINE, - Rhs: []ast.Expr{ - &ast.CallExpr{ - Fun: ast.NewIdent("checkForeignKeyFailures"), - Args: []ast.Expr{ast.NewIdent("err")}, - }, - }, - }, - Cond: &ast.BinaryExpr{ - X: ast.NewIdent("fkErr"), - Op: token.NEQ, - Y: ast.NewIdent("nil"), - }, - Body: &ast.BlockStmt{ - List: []ast.Stmt{ - &ast.ReturnStmt{ - Results: []ast.Expr{ast.NewIdent("fkErr")}, - }, - }, - }, - Else: func() *ast.IfStmt { - panicStmt := &ast.ExprStmt{ - X: &ast.CallExpr{ - Fun: ast.NewIdent("panic"), - Args: []ast.Expr{ast.NewIdent("err")}, - }, - } - TrailingComments[panicStmt] = "not a foreign key error" - return &ast.IfStmt{ - Cond: &ast.BinaryExpr{ - X: ast.NewIdent("err"), - Op: token.NEQ, - Y: ast.NewIdent("nil"), - }, - Body: &ast.BlockStmt{ - List: []ast.Stmt{panicStmt}, - }, - } - }(), - }, - ) - }(), - &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{mustCall(&ast.CallExpr{ - Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")}, - Args: []ast.Expr{}, - })}, - }}, - }, - ), - }, - Else: &ast.BlockStmt{ - // Do update - List: []ast.Stmt{ - Comment("Do update"), - &ast.AssignStmt{ - Lhs: []ast.Expr{ast.NewIdent("result")}, - Tok: token.DEFINE, - 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")}}}}}}}}, - }, + namedExecStmt := func(stmt string) []ast.Stmt { + queryStmt := &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: dbDB, Sel: ast.NewIdent("NamedExec")}, + Args: []ast.Expr{ + &ast.BasicLit{Kind: token.STRING, Value: "`" + stmt + "`"}, + ast.NewIdent(tbl.VarName), }, - }, - }) + } + if !hasFks { + // No foreign key checking needed; just use `Must` for brevity + return []ast.Stmt{&ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent("result")}, + Tok: token.DEFINE, + Rhs: []ast.Expr{mustCall(queryStmt)}, + }} + } + // There's foreign keys + return []ast.Stmt{ + // result, err := db.DB.NamedExec(`...`, u) + &ast.AssignStmt{ + Lhs: []ast.Expr{ + ast.NewIdent("result"), + ast.NewIdent("err"), + }, + Tok: token.DEFINE, + Rhs: []ast.Expr{queryStmt}, + }, + // if fkErr := checkForeignKeyFailures(err); fkErr != nil { return fkErr } else if err != nil { panic(err) } + &ast.IfStmt{ + Init: &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent("fkErr")}, + Tok: token.DEFINE, + Rhs: []ast.Expr{ + &ast.CallExpr{ + Fun: ast.NewIdent("checkForeignKeyFailures"), + Args: []ast.Expr{ast.NewIdent("err")}, + }, + }, + }, + Cond: &ast.BinaryExpr{ + X: ast.NewIdent("fkErr"), + Op: token.NEQ, + Y: ast.NewIdent("nil"), + }, + Body: &ast.BlockStmt{ + List: []ast.Stmt{ + &ast.ReturnStmt{ + Results: []ast.Expr{ast.NewIdent("fkErr")}, + }, + }, + }, + Else: func() *ast.IfStmt { + panicStmt := &ast.ExprStmt{ + X: &ast.CallExpr{ + Fun: ast.NewIdent("panic"), + Args: []ast.Expr{ast.NewIdent("err")}, + }, + } + TrailingComments[panicStmt] = "not a foreign key error" + return &ast.IfStmt{ + Cond: &ast.BinaryExpr{ + X: ast.NewIdent("err"), + Op: token.NEQ, + Y: ast.NewIdent("nil"), + }, + Body: &ast.BlockStmt{ + List: []ast.Stmt{panicStmt}, + }, + } + }(), + }, + } + } + + 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{ + 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{mustCall(&ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent("result"), Sel: ast.NewIdent("LastInsertId")}, + Args: []ast.Expr{}, + })}, + }}, + }, + ), + }, + Else: &ast.BlockStmt{ + // Do update + List: append( + []ast.Stmt{Comment("Do update")}, + append( + namedExecStmt(updateStmt), + PanicIfRowsAffected(tbl), + )..., + ), + }, + }) + } if hasFks { // 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")}}) @@ -442,6 +470,61 @@ func getByIDFuncName(tblname string) string { 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. // E.g., a table with `table.TypeName = "foods"` will produce a "GetFoodByID()" function. func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl { @@ -474,13 +557,12 @@ func GenerateGetItemByIDFunc(tbl schema.Table) *ast.FuncDecl { }, } - funcDecl := &ast.FuncDecl{ + return &ast.FuncDecl{ Recv: dbRecv, Name: ast.NewIdent(getByIDFuncName(tbl.TableName)), Type: &ast.FuncType{Params: arg, Results: result}, Body: funcBody, } - return funcDecl } // GenerateGetItemByUniqColFunc produces an AST for the `GetXyzByID()` function. @@ -581,10 +663,12 @@ func GenerateGetAllItemsFunc(tbl schema.Table) *ast.FuncDecl { // 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 { - arg := &ast.FieldList{List: []*ast.Field{{ - Names: []*ast.Ident{ast.NewIdent(tbl.VarName)}, - Type: ast.NewIdent(tbl.GoTypeName), - }}} + colNames := []string{} + for _, c := range tbl.PrimaryKeyColumns() { + 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{ List: []ast.Stmt{ @@ -592,41 +676,24 @@ func GenerateDeleteItemFunc(tbl schema.Table) *ast.FuncDecl { Lhs: []ast.Expr{ast.NewIdent("result")}, Tok: token.DEFINE, 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{ - &ast.BasicLit{Kind: token.STRING, Value: "`delete from " + tbl.TableName + " where rowid = ?`"}, - &ast.SelectorExpr{X: ast.NewIdent(tbl.VarName), Sel: ast.NewIdent("ID")}, + &ast.BasicLit{Kind: token.STRING, Value: sqlStr}, + 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("\"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")}, - }, - }}, - }}, - }}, - }, + PanicIfRowsAffected(tbl), }, } funcDecl := &ast.FuncDecl{ Recv: dbRecv, 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, } return funcDecl diff --git a/pkg/schema/table.go b/pkg/schema/table.go index cd98bd4..adda822 100644 --- a/pkg/schema/table.go +++ b/pkg/schema/table.go @@ -52,7 +52,12 @@ func (c Column) GoVarName() string { return strings.ToLower(c.ForeignKeyTargetTable)[0:1] + "ID" } - // Otherwise, just lowercase the field name + // 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 { fieldname := c.GoFieldName() return strings.ToLower(fieldname)[0:1] + fieldname[1:] }