-
Notifications
You must be signed in to change notification settings - Fork 0
⚡ Bolt: Implement prepared statements for Insert, Update, and Delete #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
cungminh2710
merged 2 commits into
main
from
feat/prepared-iud-statements-5865195085006225899
May 31, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| package rain | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "sync" | ||
|
|
||
| "github.com/hyperlocalise/rain-orm/pkg/schema" | ||
| ) | ||
|
|
||
| // PreparedInsertQuery is a prepared INSERT query with reusable named argument binding. | ||
| type PreparedInsertQuery struct { | ||
| table *schema.TableDef | ||
| compiled compiledQuery | ||
| stmt *sql.Stmt | ||
| closeOnce sync.Once | ||
| closeErr error | ||
| } | ||
|
|
||
| // Exec executes the prepared INSERT query. | ||
| func (p *PreparedInsertQuery) Exec(ctx context.Context, args PreparedArgs) (sql.Result, error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return p.stmt.ExecContext(ctx, bound...) | ||
| } | ||
|
|
||
| // Scan executes the prepared INSERT ... RETURNING query and scans results into dest. | ||
| func (p *PreparedInsertQuery) Scan(ctx context.Context, args PreparedArgs, dest any) (err error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| rows, err := p.stmt.QueryContext(ctx, bound...) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer closeRows(rows, &err) | ||
|
|
||
| return scanRowsAgainstTable(rows, dest, p.table) | ||
| } | ||
|
|
||
| // Close closes the prepared statement. | ||
| func (p *PreparedInsertQuery) Close() error { | ||
| p.closeOnce.Do(func() { | ||
| if p.stmt != nil { | ||
| p.closeErr = p.stmt.Close() | ||
| } | ||
| }) | ||
| return p.closeErr | ||
| } | ||
|
|
||
| // PreparedUpdateQuery is a prepared UPDATE query with reusable named argument binding. | ||
| type PreparedUpdateQuery struct { | ||
| table *schema.TableDef | ||
| compiled compiledQuery | ||
| stmt *sql.Stmt | ||
| closeOnce sync.Once | ||
| closeErr error | ||
| } | ||
|
|
||
| // Exec executes the prepared UPDATE query. | ||
| func (p *PreparedUpdateQuery) Exec(ctx context.Context, args PreparedArgs) (sql.Result, error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return p.stmt.ExecContext(ctx, bound...) | ||
| } | ||
|
|
||
| // Scan executes the prepared UPDATE ... RETURNING query and scans results into dest. | ||
| func (p *PreparedUpdateQuery) Scan(ctx context.Context, args PreparedArgs, dest any) (err error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| rows, err := p.stmt.QueryContext(ctx, bound...) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer closeRows(rows, &err) | ||
|
|
||
| return scanRowsAgainstTable(rows, dest, p.table) | ||
| } | ||
|
|
||
| // Close closes the prepared statement. | ||
| func (p *PreparedUpdateQuery) Close() error { | ||
| p.closeOnce.Do(func() { | ||
| if p.stmt != nil { | ||
| p.closeErr = p.stmt.Close() | ||
| } | ||
| }) | ||
| return p.closeErr | ||
| } | ||
|
|
||
| // PreparedDeleteQuery is a prepared DELETE query with reusable named argument binding. | ||
| type PreparedDeleteQuery struct { | ||
| table *schema.TableDef | ||
| compiled compiledQuery | ||
| stmt *sql.Stmt | ||
| closeOnce sync.Once | ||
| closeErr error | ||
| } | ||
|
|
||
| // Exec executes the prepared DELETE query. | ||
| func (p *PreparedDeleteQuery) Exec(ctx context.Context, args PreparedArgs) (sql.Result, error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return p.stmt.ExecContext(ctx, bound...) | ||
| } | ||
|
|
||
| // Scan executes the prepared DELETE ... RETURNING query and scans results into dest. | ||
| func (p *PreparedDeleteQuery) Scan(ctx context.Context, args PreparedArgs, dest any) (err error) { | ||
| bound, err := p.compiled.bind(args) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| rows, err := p.stmt.QueryContext(ctx, bound...) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer closeRows(rows, &err) | ||
|
|
||
| return scanRowsAgainstTable(rows, dest, p.table) | ||
| } | ||
|
|
||
| // Close closes the prepared statement. | ||
| func (p *PreparedDeleteQuery) Close() error { | ||
| p.closeOnce.Do(func() { | ||
| if p.stmt != nil { | ||
| p.closeErr = p.stmt.Close() | ||
| } | ||
| }) | ||
| return p.closeErr | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| package rain | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/hyperlocalise/rain-orm/pkg/schema" | ||
| ) | ||
|
|
||
| func TestPreparedInsertCompile(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| db, _ := OpenDialect("postgres") | ||
| users := schema.Define("users", func(t *struct { | ||
| schema.TableModel | ||
| ID *schema.Column[int64] | ||
| Email *schema.Column[string] | ||
| Name *schema.Column[string] | ||
| }, | ||
| ) { | ||
| t.ID = t.BigSerial("id").PrimaryKey() | ||
| t.Email = t.VarChar("email", 255) | ||
| t.Name = t.Text("name") | ||
| }) | ||
|
|
||
| q := db.Insert(). | ||
| Table(users). | ||
| Set(users.Email, schema.Placeholder("email")). | ||
| Set(users.Name, schema.Placeholder("name")) | ||
|
|
||
| compiled, err := q.compile() | ||
| if err != nil { | ||
| t.Fatalf("compile failed: %v", err) | ||
| } | ||
|
|
||
| wantSQL := `INSERT INTO "users" ("email", "name") VALUES ($1, $2)` | ||
| if compiled.sql != wantSQL { | ||
| t.Errorf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, compiled.sql) | ||
| } | ||
| if !compiled.hasNames { | ||
| t.Errorf("expected compiled query to have names") | ||
| } | ||
| } | ||
|
|
||
| func TestPreparedUpdateCompile(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| db, _ := OpenDialect("postgres") | ||
| users := schema.Define("users", func(t *struct { | ||
| schema.TableModel | ||
| ID *schema.Column[int64] | ||
| Name *schema.Column[string] | ||
| }, | ||
| ) { | ||
| t.ID = t.BigSerial("id").PrimaryKey() | ||
| t.Name = t.Text("name") | ||
| }) | ||
|
|
||
| q := db.Update(). | ||
| Table(users). | ||
| Set(users.Name, schema.Placeholder("new_name")). | ||
| Where(users.ID.EqExpr(schema.Placeholder("id"))) | ||
|
|
||
| compiled, err := q.compile() | ||
| if err != nil { | ||
| t.Fatalf("compile failed: %v", err) | ||
| } | ||
|
|
||
| wantSQL := `UPDATE "users" SET "name" = $1 WHERE "users"."id" = $2` | ||
| if compiled.sql != wantSQL { | ||
| t.Errorf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, compiled.sql) | ||
| } | ||
| } | ||
|
|
||
| func TestPreparedDeleteCompile(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| db, _ := OpenDialect("postgres") | ||
| users := schema.Define("users", func(t *struct { | ||
| schema.TableModel | ||
| ID *schema.Column[int64] | ||
| }, | ||
| ) { | ||
| t.ID = t.BigSerial("id").PrimaryKey() | ||
| }) | ||
|
|
||
| q := db.Delete(). | ||
| Table(users). | ||
| Where(users.ID.EqExpr(schema.Placeholder("id"))) | ||
|
|
||
| compiled, err := q.compile() | ||
| if err != nil { | ||
| t.Fatalf("compile failed: %v", err) | ||
| } | ||
|
|
||
| wantSQL := `DELETE FROM "users" WHERE "users"."id" = $1` | ||
| if compiled.sql != wantSQL { | ||
| t.Errorf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, compiled.sql) | ||
| } | ||
| } | ||
|
|
||
| func TestPreparedInsertScanInternal(t *testing.T) { | ||
| // This test ensures that PreparedInsertQuery has access to the correct table metadata. | ||
| users := schema.Define("users", func(t *struct { | ||
| schema.TableModel | ||
| ID *schema.Column[int64] | ||
| Email *schema.Column[string] | ||
| }, | ||
| ) { | ||
| t.ID = t.BigSerial("id").PrimaryKey() | ||
| t.Email = t.VarChar("email", 255) | ||
| }) | ||
|
|
||
| prepared := &PreparedInsertQuery{ | ||
| table: users.TableDef(), | ||
| } | ||
|
|
||
| if prepared.table.Name != "users" { | ||
| t.Errorf("expected table name users, got %s", prepared.table.Name) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package rain_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/hyperlocalise/rain-orm/pkg/rain" | ||
| "github.com/hyperlocalise/rain-orm/pkg/schema" | ||
| ) | ||
|
|
||
| func TestPreparedToSQLReturnsErrorWithPlaceholders(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| db, err := rain.OpenDialect("postgres") | ||
| if err != nil { | ||
| t.Fatalf("OpenDialect returned error: %v", err) | ||
| } | ||
| users, _ := defineTables() | ||
|
|
||
| t.Run("insert", func(t *testing.T) { | ||
| _, _, err := db.Insert(). | ||
| Table(users). | ||
| Set(users.Email, schema.Placeholder("email")). | ||
| ToSQL() | ||
| if err != rain.ErrPreparedArgsRequired { | ||
| t.Errorf("expected ErrPreparedArgsRequired, got %v", err) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("update", func(t *testing.T) { | ||
| _, _, err := db.Update(). | ||
| Table(users). | ||
| Set(users.Name, schema.Placeholder("name")). | ||
| Where(users.ID.Eq(int64(1))). | ||
| ToSQL() | ||
| if err != rain.ErrPreparedArgsRequired { | ||
| t.Errorf("expected ErrPreparedArgsRequired, got %v", err) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("delete", func(t *testing.T) { | ||
| _, _, err := db.Delete(). | ||
| Table(users). | ||
| Where(users.ID.EqExpr(schema.Placeholder("id"))). | ||
| ToSQL() | ||
| if err != rain.ErrPreparedArgsRequired { | ||
| t.Errorf("expected ErrPreparedArgsRequired, got %v", err) | ||
| } | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The non-prepared
InsertQuery.Scan(),UpdateQuery.Scan(), andDeleteQuery.Scan()all guard against being called without aRETURNINGclause by checkinglen(q.returning) == 0and returning a clear"rain: insert scan requires RETURNING"error. The three preparedScanmethods have no such check. If a user prepares anINSERT/UPDATE/DELETEwithout.Returning(...)and then callsScan(), the statement executes but the database returns no columns;scanRowsAgainstTablethen fails with a confusing low-level error (e.g., a scan plan mismatch) rather than the clear sentinel the caller would expect.Fix: add a
hasReturning boolfield to each prepared type, set it tolen(q.returning) > 0in the respectivePrepare()method, and check it at the top of eachScan(). The same gap exists on lines 76–89 (PreparedUpdateQuery.Scan) and lines 121–134 (PreparedDeleteQuery.Scan).Prompt To Fix With AI