Transactions: DuckDB and TimescaleDB
Every file of the DuckDB and TimescaleDB versions side by side, with the lines that differ marked.
Configuration
sgl.yamldiffers
DuckDB
TimescaleDB
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: bank4
- name: bank5
dialect: duckdb5
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/sql12
interface:13
enabled: true14
migrate:15
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_accounts.up.sqldiffers
DuckDB
TimescaleDB
1
CREATE SEQUENCE account_ids;2
3
CREATE TABLE accounts (1
CREATE TABLE accounts (4
id BIGINT PRIMARY KEY DEFAULT nextval('account_ids'),2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,5
owner VARCHAR NOT NULL,3
owner TEXT NOT NULL,6
balance INTEGER NOT NULL CHECK (balance >= 0)4
balance INTEGER NOT NULL CHECK (balance >= 0)7
);5
);db/migrations/0001_accounts.down.sqldiffers
DuckDB
TimescaleDB
1
DROP TABLE accounts;1
DROP TABLE accounts;2
DROP SEQUENCE account_ids;Queries
OpenAccountidentical
DuckDB · 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
DuckDB · 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
DuckDB · 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
DuckDB · 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
DuckDB
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
DuckDB
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
DuckDB
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
DuckDB
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.godiffers
DuckDB
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/duckdb/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.PgError14
// does.15
type sqlStateError interface {16
error17
18
// SQLState returns the five-character SQLSTATE code.19
SQLState() string20
}21
13
// 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 transfers14
// 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.15
//24
//25
// 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 the27
// database or inside a transaction. printBalances takes a store.Querier, so the same28
// 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.30
//16
// Takes database (*sql.DB) which is a migrated database.31
// Takes database (*sql.DB) which is a migrated database.17
// Takes output (io.Writer) which receives each step and the balances.32
// Takes output (io.Writer) which receives each step and the balances.18
//33
//19
// 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.20
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 {21
queries := store.New(database)36
queries := store.New(database)22
37
23
pip, gull, err := openAccounts(ctx, database, queries)38
pip, gull, err := openAccounts(ctx, database, queries)24
if err != nil {39
if err != nil {25
return fmt.Errorf("opening accounts: %w", err)40
return fmt.Errorf("opening accounts: %w", err)26
}41
}27
fmt.Fprintln(output, "opened accounts for Pip and Gull: committed")42
fmt.Fprintln(output, "opened accounts for Pip and Gull: committed")28
if err := printBalances(ctx, queries, output); err != nil {43
if err := printBalances(ctx, queries, output, "balances"); err != nil {29
return err44
return err30
}45
}31
46
32
if err := transfer(ctx, database, queries, pip, gull, 1500); err != nil {47
if err := transfer(ctx, database, queries, output, pip, gull, 1500); err != nil {33
return fmt.Errorf("first transfer: %w", err)48
return fmt.Errorf("first transfer: %w", err)34
}49
}35
fmt.Fprintln(output, "transferred 1500 from Pip to Gull: committed")50
fmt.Fprintln(output, "transferred 1500 from Pip to Gull: committed")36
if err := printBalances(ctx, queries, output); err != nil {51
if err := printBalances(ctx, queries, output, "balances"); err != nil {37
return err52
return err38
}53
}39
54
40
// 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 when41
// 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 is42
// undone with it.57
// undone with it.43
err = transfer(ctx, database, queries, gull, pip, 9000)58
err = transfer(ctx, database, queries, output, gull, pip, 9000)44
if err == nil {59
if err == nil {45
return errors.New("an overdrawing transfer committed")60
return errors.New("an overdrawing transfer committed")46
}61
}47
fmt.Fprintf(output, "transferred 9000 from Gull to Pip: rolled back (%v)\n", err)62
48
return printBalances(ctx, queries, output)63
// 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,65
// not the message, as the wording differs between servers and versions.66
failure, hasState := errors.AsType[sqlStateError](err)67
if !hasState {68
return fmt.Errorf("an overdrawing transfer failed without a SQLSTATE: %w", err)69
}70
fmt.Fprintf(output, "transferred 9000 from Gull to Pip: rolled back (SQLSTATE %s)\n", failure.SQLState())71
return printBalances(ctx, queries, output, "balances")49
}72
}50
73
51
// 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.52
// 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 transaction53
// 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.54
//77
//55
// Takes database (*sql.DB) which begins the transaction.78
// Takes database (*sql.DB) which begins the transaction.56
// Takes queries (*store.Queries) which are bound to it.79
// Takes queries (*store.Queries) which are bound to it.57
//80
//58
// Returns pipID (int64) which is Pip's account ID.81
// Returns pipID (int64) which is Pip's account ID.59
// Returns gullID (int64) which is Gull's account ID.82
// Returns gullID (int64) which is Gull's account ID.60
// 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.61
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) {62
transaction, err := database.BeginTx(ctx, nil)85
transaction, err := database.BeginTx(ctx, nil)63
if err != nil {86
if err != nil {64
return 0, 0, err87
return 0, 0, err65
}88
}66
// 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 early67
// return.90
// return.68
defer func() { _ = transaction.Rollback() }()91
defer func() { _ = transaction.Rollback() }()69
92
70
inTransaction := queries.WithTx(transaction)93
inTransaction := queries.WithTx(transaction)71
pip, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Pip", Balance: 5000})94
pip, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Pip", Balance: 5000})72
if err != nil {95
if err != nil {73
return 0, 0, err96
return 0, 0, err74
}97
}75
gull, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Gull", Balance: 2000})98
gull, err := inTransaction.OpenAccount(ctx, store.OpenAccountParams{Owner: "Gull", Balance: 2000})76
if err != nil {99
if err != nil {77
return 0, 0, err100
return 0, 0, err78
}101
}79
return pip.ID, gull.ID, transaction.Commit()102
return pip.ID, gull.ID, transaction.Commit()80
}103
}81
104
82
// 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,83
// 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 it84
// returns an error.107
// returns an error. After the credit it prints the balances the transaction sees, through108
// the same printBalances the caller uses.85
//109
//86
// Takes database (*sql.DB) which begins the transaction.110
// Takes database (*sql.DB) which begins the transaction.87
// 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.88
// Takes from (int64) which is the account to debit.113
// Takes from (int64) which is the account to debit.89
// Takes to (int64) which is the account to credit.114
// Takes to (int64) which is the account to credit.90
// Takes amount (int32) which is the sum to move, in pence.115
// Takes amount (int32) which is the sum to move, in pence.91
//116
//92
// 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.93
func transfer(ctx context.Context, database *sql.DB, queries *store.Queries, 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 {94
return queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {119
return queries.RunInTx(ctx, database, func(inTransaction *store.Queries) error {95
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 {96
return err121
return err97
}122
}123
if err := printBalances(ctx, inTransaction, output, "inside the transaction"); err != nil {124
return err125
}98
return inTransaction.Withdraw(ctx, store.WithdrawParams{Amount: amount, ID: from})126
return inTransaction.Withdraw(ctx, store.WithdrawParams{Amount: amount, ID: from})99
})127
})100
}128
}101
129
102
// printBalances writes every account's balance on one line.130
// 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: the132
// queries on the database, the queries bound to a transaction, or a fake in a test.103
//133
//104
// Takes queries (*store.Queries) which read the accounts.134
// Takes queries (store.Querier) which read the accounts.105
// 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.106
//137
//107
// Returns error when the accounts cannot be listed.138
// Returns error when the accounts cannot be listed.108
func printBalances(ctx context.Context, queries *store.Queries, output io.Writer) error {139
func printBalances(ctx context.Context, queries store.Querier, output io.Writer, label string) error {109
accounts, err := queries.ListAccounts(ctx)140
accounts, err := queries.ListAccounts(ctx)110
if err != nil {141
if err != nil {111
return fmt.Errorf("listing accounts: %w", err)142
return fmt.Errorf("listing accounts: %w", err)112
}143
}113
fmt.Fprint(output, " balances:")144
fmt.Fprintf(output, " %s:", label)114
for _, account := range accounts {145
for _, account := range accounts {115
fmt.Fprintf(output, " %s %d", account.Owner, account.Balance)146
fmt.Fprintf(output, " %s %d", account.Owner, account.Balance)116
}147
}117
fmt.Fprintln(output)148
fmt.Fprintln(output)118
return nil149
return nil119
}150
}