Functions in migrations: 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: market4
- name: market5
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
migrate:12
migrate:13
dsn: ${env:DATABASE_URL}13
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_catches.up.sqlidentical
PostgreSQL
TimescaleDB
1
CREATE TABLE catches (1
CREATE TABLE catches (2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,3
boat TEXT NOT NULL,3
boat TEXT NOT NULL,4
species TEXT NOT NULL,4
species TEXT NOT NULL,5
weight_grams INTEGER NOT NULL5
weight_grams INTEGER NOT NULL6
);6
);7
7
8
CREATE TABLE prices (8
CREATE TABLE prices (9
species TEXT PRIMARY KEY,9
species TEXT PRIMARY KEY,10
pence_per_kilo INTEGER NOT NULL10
pence_per_kilo INTEGER NOT NULL11
);11
);db/migrations/0001_catches.down.sqlidentical
PostgreSQL
TimescaleDB
1
DROP TABLE prices;1
DROP TABLE prices;2
DROP TABLE catches;2
DROP TABLE catches;db/migrations/0002_market_functions.up.sqlidentical
PostgreSQL
TimescaleDB
1
CREATE FUNCTION price_pence(weight_grams INTEGER, pence_per_kilo INTEGER) RETURNS INTEGER1
CREATE FUNCTION price_pence(weight_grams INTEGER, pence_per_kilo INTEGER) RETURNS INTEGER2
LANGUAGE sql IMMUTABLE STRICT2
LANGUAGE sql IMMUTABLE STRICT3
AS $$ SELECT weight_grams * pence_per_kilo / 1000 $$;3
AS $$ SELECT weight_grams * pence_per_kilo / 1000 $$;4
4
5
CREATE FUNCTION boat_haul(boat_name TEXT)5
CREATE FUNCTION boat_haul(boat_name TEXT)6
RETURNS TABLE (species TEXT, weight_grams INTEGER)6
RETURNS TABLE (species TEXT, weight_grams INTEGER)7
LANGUAGE sql STABLE7
LANGUAGE sql STABLE8
AS $$ SELECT species, weight_grams FROM catches WHERE boat = boat_name $$;8
AS $$ SELECT species, weight_grams FROM catches WHERE boat = boat_name $$;9
9
10
CREATE FUNCTION land_catch(boat_name TEXT, caught TEXT, grams INTEGER) RETURNS BIGINT10
CREATE FUNCTION land_catch(boat_name TEXT, caught TEXT, grams INTEGER) RETURNS BIGINT11
LANGUAGE plpgsql VOLATILE11
LANGUAGE plpgsql VOLATILE12
AS $$12
AS $$13
DECLARE13
DECLARE14
landed BIGINT;14
landed BIGINT;15
BEGIN15
BEGIN16
INSERT INTO catches (boat, species, weight_grams) VALUES (boat_name, caught, grams)16
INSERT INTO catches (boat, species, weight_grams) VALUES (boat_name, caught, grams)17
RETURNING id INTO landed;17
RETURNING id INTO landed;18
RETURN landed;18
RETURN landed;19
END;19
END;20
$$;20
$$;21
21
22
-- sgl.migration(readonly: true)22
-- sgl.migration(readonly: true)23
CREATE FUNCTION species_price(wanted TEXT) RETURNS INTEGER23
CREATE FUNCTION species_price(wanted TEXT) RETURNS INTEGER24
LANGUAGE sql VOLATILE24
LANGUAGE sql VOLATILE25
AS $$ SELECT pence_per_kilo FROM prices WHERE species = wanted $$;25
AS $$ SELECT pence_per_kilo FROM prices WHERE species = wanted $$;db/migrations/0002_market_functions.down.sqlidentical
PostgreSQL
TimescaleDB
1
DROP FUNCTION species_price(TEXT);1
DROP FUNCTION species_price(TEXT);2
DROP FUNCTION land_catch(TEXT, TEXT, INTEGER);2
DROP FUNCTION land_catch(TEXT, TEXT, INTEGER);3
DROP FUNCTION boat_haul(TEXT);3
DROP FUNCTION boat_haul(TEXT);4
DROP FUNCTION price_pence(INTEGER, INTEGER);4
DROP FUNCTION price_pence(INTEGER, INTEGER);Queries
SetPriceidentical
PostgreSQL · db/queries/market.sql
TimescaleDB · db/queries/market.sql
1
-- sgl.query(SetPrice, exec)1
-- sgl.query(SetPrice, exec)2
INSERT INTO prices (species, pence_per_kilo) VALUES ($1, $2);2
INSERT INTO prices (species, pence_per_kilo) VALUES ($1, $2);LandCatchidentical
PostgreSQL · db/queries/market.sql
TimescaleDB · db/queries/market.sql
1
-- sgl.query(LandCatch, one)1
-- sgl.query(LandCatch, one)2
SELECT land_catch(:boat, :species, :grams) AS id;2
SELECT land_catch(:boat, :species, :grams) AS id;SpeciesPriceidentical
PostgreSQL · db/queries/market.sql
TimescaleDB · db/queries/market.sql
1
-- sgl.query(SpeciesPrice, one)1
-- sgl.query(SpeciesPrice, one)2
SELECT species_price(:species) AS pence_per_kilo;2
SELECT species_price(:species) AS pence_per_kilo;BoatHaulidentical
PostgreSQL · db/queries/market.sql
TimescaleDB · db/queries/market.sql
1
-- sgl.query(BoatHaul, many)1
-- sgl.query(BoatHaul, many)2
SELECT haul.species, haul.weight_grams, price_pence(haul.weight_grams, prices.pence_per_kilo) AS value_pence2
SELECT haul.species, haul.weight_grams, price_pence(haul.weight_grams, prices.pence_per_kilo) AS value_pence3
FROM boat_haul(:boat) AS haul3
FROM boat_haul(:boat) AS haul4
JOIN prices ON prices.species = haul.species4
JOIN prices ON prices.species = haul.species5
ORDER BY haul.species;5
ORDER BY haul.species;What sgl generates
SetPriceidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) SetPrice(ctx context.Context, params SetPriceParams) error1
func (queries *Queries) SetPrice(ctx context.Context, params SetPriceParams) error2
2
3
type SetPriceParams struct {3
type SetPriceParams struct {4
Species string4
Species string5
PencePerKilo int325
PencePerKilo int326
}6
}LandCatchidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) LandCatch(ctx context.Context, params LandCatchParams) (LandCatchRow, error)1
func (queries *Queries) LandCatch(ctx context.Context, params LandCatchParams) (LandCatchRow, error)2
2
3
type LandCatchParams struct {3
type LandCatchParams struct {4
Boat string4
Boat string5
Species string5
Species string6
Grams int326
Grams int327
}7
}8
8
9
type LandCatchRow struct {9
type LandCatchRow struct {10
ID *int64 `json:"id"`10
ID *int64 `json:"id"`11
}11
}SpeciesPriceidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) SpeciesPrice(ctx context.Context, species string) (SpeciesPriceRow, error)1
func (queries *Queries) SpeciesPrice(ctx context.Context, species string) (SpeciesPriceRow, error)2
2
3
type SpeciesPriceRow struct {3
type SpeciesPriceRow struct {4
PencePerKilo *int32 `json:"pence_per_kilo"`4
PencePerKilo *int32 `json:"pence_per_kilo"`5
}5
}BoatHaulidentical
PostgreSQL
TimescaleDB
1
func (queries *Queries) BoatHaul(ctx context.Context, boat string) ([]BoatHaulRow, error)1
func (queries *Queries) BoatHaul(ctx context.Context, boat string) ([]BoatHaulRow, error)2
2
3
type BoatHaulRow struct {3
type BoatHaulRow struct {4
Species *string `json:"species"`4
Species *string `json:"species"`5
WeightGrams *int32 `json:"weight_grams"`5
WeightGrams *int32 `json:"weight_grams"`6
ValuePence *int32 `json:"value_pence"`6
ValuePence *int32 `json:"value_pence"`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
"fmt"6
"fmt"7
"io"7
"io"8
8
9
"seagull.sh/seagull/examples/postgres/030_functions_in_migrations/internal/store"9
"seagull.sh/seagull/examples/postgres/030_functions_in_migrations/internal/store"10
)10
)11
11
12
// connection is a store.DBTX that notes its name before each statement, so the example12
// connection is a store.DBTX that notes its name before each statement, so the example13
// can say whether Seagull sent a query to the writer or the reader. Both connections wrap13
// can say whether Seagull sent a query to the writer or the reader. Both connections wrap14
// the same database here; concept 24 puts the reader on a replica.14
// the same database here; concept 24 puts the reader on a replica.15
type connection struct {15
type connection struct {16
*sql.DB16
*sql.DB17
17
18
// last receives the name of the connection that ran the latest statement.18
// last receives the name of the connection that ran the latest statement.19
last *string19
last *string20
20
21
// name labels the connection.21
// name labels the connection.22
name string22
name string23
}23
}24
24
25
// ExecContext notes the connection, then runs the statement on the database.25
// ExecContext notes the connection, then runs the statement on the database.26
//26
//27
// Takes query (string) which is the SQL to run.27
// Takes query (string) which is the SQL to run.28
// Takes arguments (...any) which are its bind values.28
// Takes arguments (...any) which are its bind values.29
//29
//30
// Returns sql.Result which is the database's result.30
// Returns sql.Result which is the database's result.31
// Returns error when the statement fails.31
// Returns error when the statement fails.32
func (c connection) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {32
func (c connection) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {33
*c.last = c.name33
*c.last = c.name34
return c.DB.ExecContext(ctx, query, arguments...)34
return c.DB.ExecContext(ctx, query, arguments...)35
}35
}36
36
37
// QueryContext notes the connection, then runs the query on the database.37
// QueryContext notes the connection, then runs the query on the database.38
//38
//39
// Takes query (string) which is the SQL to run.39
// Takes query (string) which is the SQL to run.40
// Takes arguments (...any) which are its bind values.40
// Takes arguments (...any) which are its bind values.41
//41
//42
// Returns *sql.Rows which holds the result rows.42
// Returns *sql.Rows which holds the result rows.43
// Returns error when the query fails.43
// Returns error when the query fails.44
func (c connection) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {44
func (c connection) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {45
*c.last = c.name45
*c.last = c.name46
return c.DB.QueryContext(ctx, query, arguments...)46
return c.DB.QueryContext(ctx, query, arguments...)47
}47
}48
48
49
// QueryRowContext notes the connection, then runs the query on the database.49
// QueryRowContext notes the connection, then runs the query on the database.50
//50
//51
// Takes query (string) which is the SQL to run.51
// Takes query (string) which is the SQL to run.52
// Takes arguments (...any) which are its bind values.52
// Takes arguments (...any) which are its bind values.53
//53
//54
// Returns *sql.Row which holds the result row or the error.54
// Returns *sql.Row which holds the result row or the error.55
func (c connection) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {55
func (c connection) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {56
*c.last = c.name56
*c.last = c.name57
return c.DB.QueryRowContext(ctx, query, arguments...)57
return c.DB.QueryRowContext(ctx, query, arguments...)58
}58
}59
59
60
// run prices the fish, lands three catches and reads the market back, all through the SQL60
// run prices the fish, lands three catches and reads the market back, all through the SQL61
// functions that migration 0002 creates. Seagull reads each CREATE FUNCTION when it61
// functions that migration 0002 creates. Seagull reads each CREATE FUNCTION when it62
// builds the schema: the argument and return types type the queries that call it, and62
// builds the schema: the argument and return types type the queries that call it, and63
// whether the function may change data decides whether those queries are read-only, which63
// whether the function may change data decides whether those queries are read-only, which64
// picks the writer or the reader. Volatility does not decide it.64
// picks the writer or the reader. Volatility does not decide it.65
//65
//66
// - land_catch inserts a row, so LandCatch goes to the writer;66
// - land_catch inserts a row, so LandCatch goes to the writer;67
// - price_pence and boat_haul only read data, so BoatHaul, which calls both, goes to67
// - price_pence and boat_haul only read data, so BoatHaul, which calls both, goes to68
// the reader. boat_haul RETURNS TABLE, so it is a relation with typed columns, and68
// the reader. boat_haul RETURNS TABLE, so it is a relation with typed columns, and69
// its $1 is typed from the function's TEXT argument;69
// its $1 is typed from the function's TEXT argument;70
// - species_price is declared VOLATILE, but its body only reads, so SpeciesPrice goes70
// - species_price is declared VOLATILE, but its body only reads, so SpeciesPrice goes71
// to the reader. The sgl.migration(readonly: true) line above it in the migration71
// to the reader. The sgl.migration(readonly: true) line above it in the migration72
// states that outright, as a function whose body Seagull cannot read needs.72
// states that outright, as a function whose body Seagull cannot read needs.73
//73
//74
// A function's result can be NULL, so the columns are pointers: species_price finds no74
// A function's result can be NULL, so the columns are pointers: species_price finds no75
// row for a fish with no price. price_pence is STRICT, so a NULL argument gives a NULL75
// row for a fish with no price. price_pence is STRICT, so a NULL argument gives a NULL76
// result; here its arguments come from boat_haul, which may return NULLs.76
// result; here its arguments come from boat_haul, which may return NULLs.77
//77
//78
// Takes database (*sql.DB) which is a migrated database.78
// Takes database (*sql.DB) which is a migrated database.79
// Takes output (io.Writer) which receives each call's connection and result.79
// Takes output (io.Writer) which receives each call's connection and result.80
//80
//81
// Returns error when a query fails.81
// Returns error when a query fails.82
func run(ctx context.Context, database *sql.DB, output io.Writer) error {82
func run(ctx context.Context, database *sql.DB, output io.Writer) error {83
var last string83
var last string84
queries := store.NewWithReplica(84
queries := store.NewWithReplica(85
connection{DB: database, last: &last, name: "writer"},85
connection{DB: database, last: &last, name: "writer"},86
connection{DB: database, last: &last, name: "reader"},86
connection{DB: database, last: &last, name: "reader"},87
)87
)88
88
89
prices := []store.SetPriceParams{89
prices := []store.SetPriceParams{90
{Species: "crab", PencePerKilo: 1200},90
{Species: "crab", PencePerKilo: 1200},91
{Species: "mackerel", PencePerKilo: 450},91
{Species: "mackerel", PencePerKilo: 450},92
{Species: "pollock", PencePerKilo: 800},92
{Species: "pollock", PencePerKilo: 800},93
}93
}94
for _, price := range prices {94
for _, price := range prices {95
if err := queries.SetPrice(ctx, price); err != nil {95
if err := queries.SetPrice(ctx, price); err != nil {96
return fmt.Errorf("pricing %s: %w", price.Species, err)96
return fmt.Errorf("pricing %s: %w", price.Species, err)97
}97
}98
}98
}99
99
100
catches := []store.LandCatchParams{100
catches := []store.LandCatchParams{101
{Boat: "Kittiwake", Species: "mackerel", Grams: 12000},101
{Boat: "Kittiwake", Species: "mackerel", Grams: 12000},102
{Boat: "Kittiwake", Species: "crab", Grams: 3500},102
{Boat: "Kittiwake", Species: "crab", Grams: 3500},103
{Boat: "Puffin", Species: "pollock", Grams: 8000},103
{Boat: "Puffin", Species: "pollock", Grams: 8000},104
}104
}105
for _, catch := range catches {105
for _, catch := range catches {106
landed, err := queries.LandCatch(ctx, catch)106
landed, err := queries.LandCatch(ctx, catch)107
if err != nil {107
if err != nil {108
return fmt.Errorf("landing %s: %w", catch.Species, err)108
return fmt.Errorf("landing %s: %w", catch.Species, err)109
}109
}110
fmt.Fprintf(output, "LandCatch on the %s: catch %s, %dg of %s from the %s\n", last, orNull(landed.ID), catch.Grams, catch.Species, catch.Boat)110
fmt.Fprintf(output, "LandCatch on the %s: catch %s, %dg of %s from the %s\n", last, orNull(landed.ID), catch.Grams, catch.Species, catch.Boat)111
}111
}112
112
113
for _, species := range []string{"crab", "halibut"} {113
for _, species := range []string{"crab", "halibut"} {114
price, err := queries.SpeciesPrice(ctx, species)114
price, err := queries.SpeciesPrice(ctx, species)115
if err != nil {115
if err != nil {116
return fmt.Errorf("pricing %s: %w", species, err)116
return fmt.Errorf("pricing %s: %w", species, err)117
}117
}118
if price.PencePerKilo == nil {118
if price.PencePerKilo == nil {119
fmt.Fprintf(output, "SpeciesPrice on the %s: %s has no price\n", last, species)119
fmt.Fprintf(output, "SpeciesPrice on the %s: %s has no price\n", last, species)120
continue120
continue121
}121
}122
fmt.Fprintf(output, "SpeciesPrice on the %s: %s fetches %dp a kilo\n", last, species, *price.PencePerKilo)122
fmt.Fprintf(output, "SpeciesPrice on the %s: %s fetches %dp a kilo\n", last, species, *price.PencePerKilo)123
}123
}124
124
125
haul, err := queries.BoatHaul(ctx, "Kittiwake")125
haul, err := queries.BoatHaul(ctx, "Kittiwake")126
if err != nil {126
if err != nil {127
return fmt.Errorf("reading the Kittiwake's haul: %w", err)127
return fmt.Errorf("reading the Kittiwake's haul: %w", err)128
}128
}129
fmt.Fprintf(output, "BoatHaul on the %s: the Kittiwake's haul\n", last)129
fmt.Fprintf(output, "BoatHaul on the %s: the Kittiwake's haul\n", last)130
for _, row := range haul {130
for _, row := range haul {131
fmt.Fprintf(output, " %-9s %6sg %5sp\n", orNull(row.Species), orNull(row.WeightGrams), orNull(row.ValuePence))131
fmt.Fprintf(output, " %-9s %6sg %5sp\n", orNull(row.Species), orNull(row.WeightGrams), orNull(row.ValuePence))132
}132
}133
return nil133
return nil134
}134
}135
135
136
// orNull formats a nullable field, or returns NULL when the field is nil.136
// orNull formats a nullable field, or returns NULL when the field is nil.137
//137
//138
// Takes value (*T) which is a nullable field.138
// Takes value (*T) which is a nullable field.139
//139
//140
// Returns string which is the text to print.140
// Returns string which is the text to print.141
func orNull[T any](value *T) string {141
func orNull[T any](value *T) string {142
if value == nil {142
if value == nil {143
return "NULL"143
return "NULL"144
}144
}145
return fmt.Sprint(*value)145
return fmt.Sprint(*value)146
}146
}