Compare · 030

Functions in migrations: MariaDB and ClickHouse

Every file of the MariaDB and ClickHouse versions side by side, with the lines that differ marked.

Configuration

sgl.yamldiffers
MariaDB
ClickHouse
1version: 1
1version: 1
2
2
3databases:
3databases:
4 - name: market
4 - name: chippy
5 dialect: mysql
5 dialect: clickhouse
6 migrations: db/migrations
6 migrations: db/migrations
7 queries: db/queries
7 queries: db/queries
8 go:
8 go:
9 package: store
9 package: store
10 out: internal/store
10 out: internal/store

Migrations

db/migrations/0001_catches.up.sqldiffers
MariaDB
ClickHouse · db/migrations/0001_menu.up.sql
1CREATE TABLE catches (
1CREATE TABLE menu (
2 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
2 id UInt64,
3 boat VARCHAR(50) NOT NULL,
3 dish String,
4 species VARCHAR(50) NOT NULL,
4 price_pence UInt32
5 weight_grams INT NOT NULL
5) ENGINE = MergeTree() ORDER BY id;
6);
7
8CREATE TABLE prices (
9 species VARCHAR(50) NOT NULL PRIMARY KEY,
10 pence_per_kilo INT NOT NULL
11);
db/migrations/0001_catches.down.sqldiffers
MariaDB
ClickHouse · db/migrations/0001_menu.down.sql
1DROP TABLE prices;
1DROP TABLE menu;
2DROP TABLE catches;
db/migrations/0002_market_functions.up.sqldiffers
MariaDB
ClickHouse · db/migrations/0002_with_service.up.sql
1CREATE FUNCTION price_pence(weight_grams INT, pence_per_kilo INT) RETURNS INT
1CREATE OR REPLACE FUNCTION with_service AS pence -> intDiv(pence * 110, 100);
2DETERMINISTIC NO SQL
3RETURN weight_grams * pence_per_kilo / 1000;
4
5CREATE FUNCTION land_catch(boat_name VARCHAR(50), caught VARCHAR(50), grams INT) RETURNS BIGINT
6DETERMINISTIC MODIFIES SQL DATA
7BEGIN
8 INSERT INTO catches (boat, species, weight_grams) VALUES (boat_name, caught, grams);
9 RETURN LAST_INSERT_ID();
10END;
11
12-- sgl.migration(readonly: true)
13CREATE FUNCTION species_price(wanted VARCHAR(50)) RETURNS INT
14DETERMINISTIC MODIFIES SQL DATA
15RETURN (SELECT pence_per_kilo FROM prices WHERE species = wanted);
db/migrations/0002_market_functions.down.sqldiffers
MariaDB
ClickHouse · db/migrations/0002_with_service.down.sql
1DROP FUNCTION species_price;
1DROP FUNCTION IF EXISTS with_service;
2DROP FUNCTION land_catch;
3DROP FUNCTION price_pence;

Queries

SetPriceonly MariaDB
MariaDB · db/queries/market.sql
ClickHouse · not in this version
1-- sgl.query(SetPrice, exec)
2INSERT INTO prices (species, pence_per_kilo) VALUES (?, ?);
LandCatchonly MariaDB
MariaDB · db/queries/market.sql
ClickHouse · not in this version
1-- sgl.query(LandCatch, one)
2-- ?1 as sgl.param(boat)
3-- ?2 as sgl.param(species)
4-- ?3 as sgl.param(grams)
5SELECT land_catch(?1, ?2, ?3) AS id;
SpeciesPriceonly MariaDB
MariaDB · db/queries/market.sql
ClickHouse · not in this version
1-- sgl.query(SpeciesPrice, one)
2-- ?1 as sgl.param(species)
3SELECT species_price(?1) AS pence_per_kilo;
BoatHaulonly MariaDB
MariaDB · db/queries/market.sql
ClickHouse · not in this version
1-- sgl.query(BoatHaul, many)
2SELECT catches.species, catches.weight_grams, price_pence(catches.weight_grams, prices.pence_per_kilo) AS value_pence
3FROM catches
4JOIN prices ON prices.species = catches.species
5WHERE catches.boat = ?
6ORDER BY catches.species;
AddDishonly ClickHouse
MariaDB · not in this version
ClickHouse · db/queries/menu.sql
1-- sgl.query(AddDish, exec)
2INSERT INTO menu (id, dish, price_pence) VALUES ({id:UInt64}, {dish:String}, {price_pence:UInt32});
MenuWithServiceonly ClickHouse
MariaDB · not in this version
ClickHouse · db/queries/menu.sql
1-- sgl.query(MenuWithService, many)
2SELECT dish, price_pence, with_service(price_pence) AS total_pence
3FROM menu
4ORDER BY id;

What sgl generates

SetPriceonly MariaDB
MariaDB
ClickHouse · not in this version
1func (queries *Queries) SetPrice(ctx context.Context, params SetPriceParams) error
2
3type SetPriceParams struct {
4 Species string
5 PencePerKilo int32
6}
LandCatchonly MariaDB
MariaDB
ClickHouse · not in this version
1func (queries *Queries) LandCatch(ctx context.Context, params LandCatchParams) (LandCatchRow, error)
2
3type LandCatchParams struct {
4 Boat string
5 Species string
6 Grams int32
7}
8
9type LandCatchRow struct {
10 ID *int64 `json:"id"`
11}
SpeciesPriceonly MariaDB
MariaDB
ClickHouse · not in this version
1func (queries *Queries) SpeciesPrice(ctx context.Context, species string) (SpeciesPriceRow, error)
2
3type SpeciesPriceRow struct {
4 PencePerKilo *int32 `json:"pence_per_kilo"`
5}
BoatHaulonly MariaDB
MariaDB
ClickHouse · not in this version
1func (queries *Queries) BoatHaul(ctx context.Context, boat string) ([]BoatHaulRow, error)
2
3type BoatHaulRow struct {
4 Species string `json:"species"`
5 WeightGrams int32 `json:"weight_grams"`
6 ValuePence *int32 `json:"value_pence"`
7}
AddDishonly ClickHouse
MariaDB · not in this version
ClickHouse
1func (queries *Queries) AddDish(ctx context.Context, params AddDishParams) error
2
3type AddDishParams struct {
4 ID uint64
5 Dish string
6 PricePence uint32
7}
MenuWithServiceonly ClickHouse
MariaDB · not in this version
ClickHouse
1func (queries *Queries) MenuWithService(ctx context.Context) ([]MenuWithServiceRow, error)
2
3type MenuWithServiceRow struct {
4 Dish string `json:"dish"`
5 PricePence uint32 `json:"price_pence"`
6 TotalPence uint64 `json:"total_pence"`
7}

The program

example.godiffers
MariaDB
ClickHouse
1package main
1package main
2
2
3import (
3import (
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/mysql/030_functions_in_migrations/internal/store"
9 "seagull.sh/seagull/examples/clickhouse/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 example
12// run prices the chip shop's menu with a service charge worked out by a SQL function that
13// can say whether Seagull sent a query to the writer or the reader. Both connections wrap
13// a migration defines. In ClickHouse a SQL function is a lambda, CREATE FUNCTION
14// the same database here; concept 24 puts the reader on a replica.
14// with_service AS pence -> intDiv(pence * 110, 100), and it belongs to the server rather
15type connection struct {
15// than to one database, so the migration says CREATE OR REPLACE and the down migration
16 *sql.DB
16// drops it. A lambda can only compute from its arguments; it cannot read or write a
17
17// table, so a query that calls one stays read-only and there is no volatility to declare.
18 // last receives the name of the connection that ran the latest statement.
19 last *string
20
21 // name labels the connection.
22 name string
23}
24
25// ExecContext notes the connection, then runs the statement on the database.
26//
27// Takes query (string) which is the SQL to run.
28// Takes arguments (...any) which are its bind values.
29//
30// Returns sql.Result which is the database's result.
31// Returns error when the statement fails.
32func (c connection) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {
33 *c.last = c.name
34 return c.DB.ExecContext(ctx, query, arguments...)
35}
36
37// QueryContext notes the connection, then runs the query on the database.
38//
39// Takes query (string) which is the SQL to run.
40// Takes arguments (...any) which are its bind values.
41//
42// Returns *sql.Rows which holds the result rows.
43// Returns error when the query fails.
44func (c connection) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {
45 *c.last = c.name
46 return c.DB.QueryContext(ctx, query, arguments...)
47}
48
49// QueryRowContext notes the connection, then runs the query on the database.
50//
51// Takes query (string) which is the SQL to run.
52// Takes arguments (...any) which are its bind values.
53//
54// Returns *sql.Row which holds the result row or the error.
55func (c connection) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {
56 *c.last = c.name
57 return c.DB.QueryRowContext(ctx, query, arguments...)
58}
59
60// run prices the fish, lands three catches and reads the market back, all through the SQL
61// functions that migration 0002 creates. Seagull reads each CREATE FUNCTION when it
62// builds the schema: the argument and return types type the queries that call it, and
63// what it says about data access, along with what its body does, decides whether those
64// queries are read-only, which picks the writer or the reader.
65//
66// - land_catch says MODIFIES SQL DATA and its body inserts a row, so LandCatch goes to
67// the writer. Its body is a BEGIN ... END block, which a migration can hold without
68// DELIMITER, as Seagull splits the file into statements itself;
69// - price_pence says NO SQL, so BoatHaul, which calls it, goes to the reader;
70// - species_price says MODIFIES SQL DATA, which Seagull takes to mean it writes, but
71// its body only reads. MySQL never checks these characteristics against the body. The
72// sgl.migration(readonly: true) line above it in the migration says so, and
73// SpeciesPrice goes to the reader.
74//
75// With binary logging on, as it is by default, MySQL refuses a function that is not
76// DETERMINISTIC, NO SQL or READS SQL DATA, so the two MODIFIES SQL DATA functions are
77// declared DETERMINISTIC too.
78//
18//
79// A function's result can be NULL, so the columns are pointers: species_price finds no
19// Seagull reads the lambda from the migration and types each call from its body, with the
80// row for a fish with no price.
20// argument in place of pence. price_pence is a UInt32; ClickHouse multiplies it by 110
21// into a UInt64, the next wider type, and intDiv keeps that, so TotalPence is a uint64,
22// as the server returns. The call reads no table, so the query stays a read, and a store
23// built with NewWithReplica sends it to the reader.
81//
24//
82// Takes database (*sql.DB) which is a migrated database.
25// Takes database (*sql.DB) which is a migrated database.
83// Takes output (io.Writer) which receives each call's connection and result.
26// Takes output (io.Writer) which receives the priced menu.
84//
27//
85// Returns error when a query fails.
28// Returns error when a query fails.
86func run(ctx context.Context, database *sql.DB, output io.Writer) error {
29func run(ctx context.Context, database *sql.DB, output io.Writer) error {
87 var last string
30 queries := store.New(database)
88 queries := store.NewWithReplica(
89 connection{DB: database, last: &last, name: "writer"},
90 connection{DB: database, last: &last, name: "reader"},
91 )
92
93 prices := []store.SetPriceParams{
94 {Species: "crab", PencePerKilo: 1200},
95 {Species: "mackerel", PencePerKilo: 450},
96 {Species: "pollock", PencePerKilo: 800},
97 }
98 for _, price := range prices {
99 if err := queries.SetPrice(ctx, price); err != nil {
100 return fmt.Errorf("pricing %s: %w", price.Species, err)
101 }
102 }
103
31
104 catches := []store.LandCatchParams{
32 dishes := []store.AddDishParams{
105 {Boat: "Kittiwake", Species: "mackerel", Grams: 12000},
33 {ID: 1, Dish: "cod and chips", PricePence: 850},
106 {Boat: "Kittiwake", Species: "crab", Grams: 3500},
34 {ID: 2, Dish: "haddock and chips", PricePence: 920},
107 {Boat: "Puffin", Species: "pollock", Grams: 8000},
35 {ID: 3, Dish: "mushy peas", PricePence: 150},
108 }
109 for _, catch := range catches {
110 landed, err := queries.LandCatch(ctx, catch)
111 if err != nil {
112 return fmt.Errorf("landing %s: %w", catch.Species, err)
113 }
114 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)
115 }
36 }
116
37 for _, dish := range dishes {
117 for _, species := range []string{"crab", "halibut"} {
38 if err := queries.AddDish(ctx, dish); err != nil {
118 price, err := queries.SpeciesPrice(ctx, species)
39 return fmt.Errorf("adding %s: %w", dish.Dish, err)
119 if err != nil {
120 return fmt.Errorf("pricing %s: %w", species, err)
121 }
122 if price.PencePerKilo == nil {
123 fmt.Fprintf(output, "SpeciesPrice on the %s: %s has no price\n", last, species)
124 continue
125 }
40 }
126 fmt.Fprintf(output, "SpeciesPrice on the %s: %s fetches %dp a kilo\n", last, species, *price.PencePerKilo)
127 }
41 }
128
42
129 haul, err := queries.BoatHaul(ctx, "Kittiwake")
43 menu, err := queries.MenuWithService(ctx)
130 if err != nil {
44 if err != nil {
131 return fmt.Errorf("reading the Kittiwake's haul: %w", err)
45 return fmt.Errorf("pricing the menu: %w", err)
132 }
46 }
133 fmt.Fprintf(output, "BoatHaul on the %s: the Kittiwake's haul\n", last)
47 for _, dish := range menu {
134 for _, row := range haul {
48 fmt.Fprintf(output, "%-18s %4dp, with service %dp\n", dish.Dish, dish.PricePence, dish.TotalPence)
135 fmt.Fprintf(output, " %-9s %6dg %5sp\n", row.Species, row.WeightGrams, orNull(row.ValuePence))
136 }
49 }
137 return nil
50 return nil
138}
51}
139
140// orNull formats a nullable field, or returns NULL when the field is nil.
141//
142// Takes value (*T) which is a nullable field.
143//
144// Returns string which is the text to print.
145func orNull[T any](value *T) string {
146 if value == nil {
147 return "NULL"
148 }
149 return fmt.Sprint(*value)
150}