Transactions: PostgreSQL and TimescaleDB
PostgreSQL and TimescaleDB run the same files for this example: the SQL and the Go are identical.
Configuration
sgl.yamlidentical
PostgreSQL
TimescaleDB
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: bank4
- name: bank5
dialect: postgres5
dialect: postgres6
migrations: db/migrations6
migrations: db/migrations7
queries: db/queries7
queries: db/queries8
go:8
go:9
package: store9
package: store10
out: internal/store10
out: internal/store11
driver: database/sql11
driver: database/sql12
interface:12
interface:13
enabled: true13
enabled: true14
migrate:14
migrate:15
dsn: ${env:DATABASE_URL}15
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_accounts.up.sqlidentical
PostgreSQL
TimescaleDB
1
CREATE TABLE accounts (1
CREATE TABLE accounts (2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,3
owner TEXT NOT NULL,3
owner TEXT NOT NULL,4
balance INTEGER NOT NULL CHECK (balance >= 0)4
balance INTEGER NOT NULL CHECK (balance >= 0)5
);5
);db/migrations/0001_accounts.down.sqlidentical
PostgreSQL
TimescaleDB
1
DROP TABLE accounts;1
DROP TABLE accounts;Queries
OpenAccountidentical
PostgreSQL · db/queries/accounts.sql
TimescaleDB · db/queries/accounts.sql
1
-- sgl.query(OpenAccount, one)1
-- sgl.query(OpenAccount, one)2
INSERT INTO accounts (owner, balance) VALUES ($1, $2) RETURNING id;2
INSERT INTO accounts (owner, balance) VALUES ($1, $2) RETURNING id;Depositidentical
PostgreSQL · db/queries/accounts.sql
TimescaleDB · db/queries/accounts.sql
1
-- sgl.query(Deposit, exec)1
-- sgl.query(Deposit, exec)2
UPDATE accounts SET balance = balance + :amount WHERE id = :id;2
UPDATE accounts SET balance = balance + :amount WHERE id = :id;Withdrawidentical
PostgreSQL · db/queries/accounts.sql
TimescaleDB · db/queries/accounts.sql
1
-- sgl.query(Withdraw, exec)1
-- sgl.query(Withdraw, exec)2
UPDATE accounts SET balance = balance - :amount WHERE id = :id;2
UPDATE accounts SET balance = balance - :amount WHERE id = :id;ListAccountsidentical
PostgreSQL · db/queries/accounts.sql
TimescaleDB · db/queries/accounts.sql
1
-- sgl.query(ListAccounts, many)1
-- sgl.query(ListAccounts, many)2
SELECT id, owner, balance FROM accounts ORDER BY id;2
SELECT id, owner, balance FROM accounts ORDER BY id;What sgl generates
OpenAccountidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) OpenAccount(ctx context.Context, params OpenAccountParams) (OpenAccountRow, error)1
func (queries *Queries) OpenAccount(ctx context.Context, params OpenAccountParams) (OpenAccountRow, error)2
2
3
type OpenAccountParams struct {3
type OpenAccountParams struct {4
Owner string4
Owner string5
Balance int325
Balance int326
}6
}7
7
8
type OpenAccountRow struct {8
type OpenAccountRow struct {9
ID int64 `json:"id"`9
ID int64 `json:"id"`10
}10
}Depositidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) Deposit(ctx context.Context, params DepositParams) error1
func (queries *Queries) Deposit(ctx context.Context, params DepositParams) error2
2
3
type DepositParams struct {3
type DepositParams struct {4
Amount int324
Amount int325
ID int645
ID int646
}6
}Withdrawidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) Withdraw(ctx context.Context, params WithdrawParams) error1
func (queries *Queries) Withdraw(ctx context.Context, params WithdrawParams) error2
2
3
type WithdrawParams struct {3
type WithdrawParams struct {4
Amount int324
Amount int325
ID int645
ID int646
}6
}ListAccountsidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) ListAccounts(ctx context.Context) ([]ListAccountsRow, error)1
func (queries *Queries) ListAccounts(ctx context.Context) ([]ListAccountsRow, error)2
2
3
type ListAccountsRow struct {3
type ListAccountsRow struct {4
ID int64 `json:"id"`4
ID int64 `json:"id"`5
Owner string `json:"owner"`5
Owner string `json:"owner"`6
Balance int32 `json:"balance"`6
Balance int32 `json:"balance"`7
}7
}The program
example.goidentical
PostgreSQL
TimescaleDB
1
package main1
package main2
2
3
import (3
import (4
"context"4
"context"5
"database/sql"5
"database/sql"6
"errors"6
"errors"7
"fmt"7
"fmt"8
"io"8
"io"9
9
10
"seagull.sh/seagull/examples/postgres/023_transactions/internal/store"10
"seagull.sh/seagull/examples/postgres/023_transactions/internal/store"11
)11
)12
12
13
// sqlStateError is a server error that carries a SQLSTATE code, as pgx's *pgconn.PgError13
// sqlStateError is a server error that carries a SQLSTATE code, as pgx's *pgconn.PgError14
// does.14
// does.15
type sqlStateError interface {15
type sqlStateError interface {16
error16
error17
17
18
// SQLState returns the five-character SQLSTATE code.18
// SQLState returns the five-character SQLSTATE code.19
SQLState() string19
SQLState() string20
}20
}21
21
22
// run opens two accounts in a transaction it manages itself, then makes two transfers22
// run opens two accounts in a transaction it manages itself, then makes two transfers23
// with RunInTx: one commits, and one breaks the balance check part-way and rolls back.23
// with RunInTx: one commits, and one breaks the balance check part-way and rolls back.24
//24
//25
// sgl.yaml sets go.interface, so the generated package also declares store.Querier, an25
// sgl.yaml sets go.interface, so the generated package also declares store.Querier, an26
// interface of every query method, which *store.Queries implements whether it runs on the26
// interface of every query method, which *store.Queries implements whether it runs on the27
// database or inside a transaction. printBalances takes a store.Querier, so the same27
// database or inside a transaction. printBalances takes a store.Querier, so the same28
// function reads the committed balances and, inside each transfer, the balances the28
// function reads the committed balances and, inside each transfer, the balances the29
// transaction sees before it commits or rolls back; a test could pass it a fake instead.29
// transaction sees before it commits or rolls back; a test could pass it a fake instead.30
//30
//31
// Takes database (*sql.DB) which is a migrated database.31
// Takes database (*sql.DB) which is a migrated database.32
// Takes output (io.Writer) which receives each step and the balances.32
// Takes output (io.Writer) which receives each step and the balances.33
//33
//34
// Returns error when a query fails other than as the example expects.34
// Returns error when a query fails other than as the example expects.35
func run(ctx context.Context, database *sql.DB, output io.Writer) error {35
func run(ctx context.Context, database *sql.DB, output io.Writer) error {36
queries := store.New(database)36
queries := store.New(database)37
37
38
pip, gull, err := openAccounts(ctx, database, queries)38
pip, gull, err := openAccounts(ctx, database, queries)39
if err != nil {39
if err != nil {40
return fmt.Errorf("opening accounts: %w", err)40
return fmt.Errorf("opening accounts: %w", err)41
}41
}42
fmt.Fprintln(output, "opened accounts for Pip and Gull: committed")42
fmt.Fprintln(output, "opened accounts for Pip and Gull: committed")43
if err := printBalances(ctx, queries, output, "balances"); err != nil {43
if err := printBalances(ctx, queries, output, "balances"); err != nil {44
return err44
return err45
}45
}46
46
47
if err := transfer(ctx, database, queries, output, pip, gull, 1500); err != nil {47
if err := transfer(ctx, database, queries, output, pip, gull, 1500); err != nil {48
return fmt.Errorf("first transfer: %w", err)48
return fmt.Errorf("first transfer: %w", err)49
}49
}50
fmt.Fprintln(output, "transferred 1500 from Pip to Gull: committed")50
fmt.Fprintln(output, "transferred 1500 from Pip to Gull: committed")51
if err := printBalances(ctx, queries, output, "balances"); err != nil {51
if err := printBalances(ctx, queries, output, "balances"); err != nil {52
return err52
return err53
}53
}54
54
55
// Gull holds 3500, so this transfer credits Pip and then fails the CHECK constraint when55
// Gull holds 3500, so this transfer credits Pip and then fails the CHECK constraint when56
// it debits Gull. RunInTx rolls back, and the credit to Pip that had already run is56
// it debits Gull. RunInTx rolls back, and the credit to Pip that had already run is57
// undone with it.57
// undone with it.58
err = transfer(ctx, database, queries, output, gull, pip, 9000)58
err = transfer(ctx, database, queries, output, gull, pip, 9000)59
if err == nil {59
if err == nil {60
return errors.New("an overdrawing transfer committed")60
return errors.New("an overdrawing transfer committed")61
}61
}62
62
63
// pgx reports a server error as a *pgconn.PgError, whose SQLState method gives the63
// pgx reports a server error as a *pgconn.PgError, whose SQLState method gives the64
// standard five-character code: 23514 for a failed CHECK constraint. Match on the code,64
// standard five-character code: 23514 for a failed CHECK constraint. Match on the code,65
// not the message, as the wording differs between servers and versions.65
// not the message, as the wording differs between servers and versions.66
failure, hasState := errors.AsType[sqlStateError](err)66
failure, hasState := errors.AsType[sqlStateError](err)67
if !hasState {67
if !hasState {68
return fmt.Errorf("an overdrawing transfer failed without a SQLSTATE: %w", err)68
return fmt.Errorf("an overdrawing transfer failed without a SQLSTATE: %w", err)69
}69
}70
fmt.Fprintf(output, "transferred 9000 from Gull to Pip: rolled back (SQLSTATE %s)\n", failure.SQLState())70
fmt.Fprintf(output, "transferred 9000 from Gull to Pip: rolled back (SQLSTATE %s)\n", failure.SQLState())71
return printBalances(ctx, queries, output, "balances")71
return printBalances(ctx, queries, output, "balances")72
}72
}73
73
74
// openAccounts opens both accounts in one transaction that it begins and commits itself.74
// openAccounts opens both accounts in one transaction that it begins and commits itself.75
// WithTx returns a copy of queries bound to that transaction, which suits a transaction75
// WithTx returns a copy of queries bound to that transaction, which suits a transaction76
// begun with options or shared with code that does not use Seagull.76
// begun with options or shared with code that does not use Seagull.77
//77
//78
// Takes database (*sql.DB) which begins the transaction.78
// Takes database (*sql.DB) which begins the transaction.79
// Takes queries (*store.Queries) which are bound to it.79
// Takes queries (*store.Queries) which are bound to it.80
//80
//81
// Returns pipID (int64) which is Pip's account ID.81
// Returns pipID (int64) which is Pip's account ID.82
// Returns gullID (int64) which is Gull's account ID.82
// Returns gullID (int64) which is Gull's account ID.83
// Returns err (error) when an account cannot be opened or the commit fails.83
// Returns err (error) when an account cannot be opened or the commit fails.84
func openAccounts(ctx context.Context, database *sql.DB, queries *store.Queries) (pipID, gullID int64, err error) {84
func openAccounts(ctx context.Context, database *sql.DB, queries *store.Queries) (pipID, gullID int64, err error) {85
transaction, err := database.BeginTx(ctx, nil)85
transaction, err := database.BeginTx(ctx, nil)86
if err != nil {86
if err != nil {87
return 0, 0, err87
return 0, 0, err88
}88
}89
// Rollback after a successful Commit does nothing, so deferring it covers every early89
// Rollback after a successful Commit does nothing, so deferring it covers every early90
// return.90
// return.91
defer func() { _ = transaction.Rollback() }()91
defer func() { _ = transaction.Rollback() }()92
92
93
inTransaction := queries.WithTx(transaction)93
inTransaction := queries.WithTx(transaction)94
pip, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Pip", Balance: 5000})94
pip, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Pip", Balance: 5000})95
if err != nil {95
if err != nil {96
return 0, 0, err96
return 0, 0, err97
}97
}98
gull, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Gull", Balance: 2000})98
gull, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Gull", Balance: 2000})99
if err != nil {99
if err != nil {100
return 0, 0, err100
return 0, 0, err101
}101
}102
return pip.ID, gull.ID, transaction.Commit()102
return pip.ID, gull.ID, transaction.Commit()103
}103
}104
104
105
// transfer moves an amount between two accounts with RunInTx, which begins a transaction,105
// transfer moves an amount between two accounts with RunInTx, which begins a transaction,106
// passes fn a Queries bound to it, and commits when fn returns nil or rolls back when it106
// passes fn a Queries bound to it, and commits when fn returns nil or rolls back when it107
// returns an error. After the credit it prints the balances the transaction sees, through107
// returns an error. After the credit it prints the balances the transaction sees, through108
// the same printBalances the caller uses.108
// the same printBalances the caller uses.109
//109
//110
// Takes database (*sql.DB) which begins the transaction.110
// Takes database (*sql.DB) which begins the transaction.111
// Takes queries (*store.Queries) which run the transfer.111
// Takes queries (*store.Queries) which run the transfer.112
// Takes output (io.Writer) which receives the balances inside the transaction.112
// Takes output (io.Writer) which receives the balances inside the transaction.113
// Takes from (int64) which is the account to debit.113
// Takes from (int64) which is the account to debit.114
// Takes to (int64) which is the account to credit.114
// Takes to (int64) which is the account to credit.115
// Takes amount (int32) which is the sum to move, in pence.115
// Takes amount (int32) which is the sum to move, in pence.116
//116
//117
// Returns error when either update fails; the transaction is then rolled back.117
// Returns error when either update fails; the transaction is then rolled back.118
func transfer(ctx context.Context, database *sql.DB, queries *store.Queries, output io.Writer, from, to int64, amount int32) error {118
func transfer(ctx context.Context, database *sql.DB, queries *store.Queries, output io.Writer, from, to int64, amount int32) error {119
return queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {119
return queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {120
if err := inTransaction.Deposit(ctx, store.DepositParams{Amount: amount, ID: to}); err != nil {120
if err := inTransaction.Deposit(ctx, store.DepositParams{Amount: amount, ID: to}); err != nil {121
return err121
return err122
}122
}123
if err := printBalances(ctx, inTransaction, output, "inside the transaction"); err != nil {123
if err := printBalances(ctx, inTransaction, output, "inside the transaction"); err != nil {124
return err124
return err125
}125
}126
return inTransaction.Withdraw(ctx, store.WithdrawParams{Amount: amount, ID: from})126
return inTransaction.Withdraw(ctx, store.WithdrawParams{Amount: amount, ID: from})127
})127
})128
}128
}129
129
130
// printBalances writes every account's balance on one line. It takes the generated130
// printBalances writes every account's balance on one line. It takes the generated131
// store.Querier rather than *store.Queries, so it reads through whatever it is given: the131
// store.Querier rather than *store.Queries, so it reads through whatever it is given: the132
// queries on the database, the queries bound to a transaction, or a fake in a test.132
// queries on the database, the queries bound to a transaction, or a fake in a test.133
//133
//134
// Takes queries (store.Querier) which read the accounts.134
// Takes queries (store.Querier) which read the accounts.135
// Takes output (io.Writer) which receives the line.135
// Takes output (io.Writer) which receives the line.136
// Takes label (string) which says whose view of the balances the line shows.136
// Takes label (string) which says whose view of the balances the line shows.137
//137
//138
// Returns error when the accounts cannot be listed.138
// Returns error when the accounts cannot be listed.139
func printBalances(ctx context.Context, queries store.Querier, output io.Writer, label string) error {139
func printBalances(ctx context.Context, queries store.Querier, output io.Writer, label string) error {140
accounts, err := queries.ListAccounts(ctx)140
accounts, err := queries.ListAccounts(ctx)141
if err != nil {141
if err != nil {142
return fmt.Errorf("listing accounts: %w", err)142
return fmt.Errorf("listing accounts: %w", err)143
}143
}144
fmt.Fprintf(output, " %s:", label)144
fmt.Fprintf(output, " %s:", label)145
for _, account := range accounts {145
for _, account := range accounts {146
fmt.Fprintf(output, " %s %d", account.Owner, account.Balance)146
fmt.Fprintf(output, " %s %d", account.Owner, account.Balance)147
}147
}148
fmt.Fprintln(output)148
fmt.Fprintln(output)149
return nil149
return nil150
}150
}