The runtime query builder: PostgreSQL and ClickHouse
Every file of the PostgreSQL and ClickHouse versions side by side, with the lines that differ marked. 4 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
PostgreSQL
ClickHouse
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: pier4
- name: pier5
dialect: postgres5
dialect: clickhouse6
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/sqlMigrations
db/migrations/0001_lost_property.up.sqldiffers
PostgreSQL
ClickHouse
1
CREATE TABLE lost_property (1
CREATE TABLE lost_property (2
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,2
id UInt64,3
item TEXT NOT NULL,3
item String,4
colour TEXT NOT NULL,4
colour String,5
found_at TEXT NOT NULL,5
found_at String,6
days_held BIGINT NOT NULL,6
days_held UInt32,7
claimed BOOLEAN NOT NULL DEFAULT false,7
claimed Bool DEFAULT false,8
owner_phone TEXT8
owner_phone Nullable(String)9
);9
) ENGINE = MergeTree() ORDER BY id;db/migrations/0001_lost_property.down.sqlidentical
PostgreSQL
ClickHouse
1
DROP TABLE lost_property;1
DROP TABLE lost_property;Queries
HandIndiffers
PostgreSQL · db/queries/lost_property.sql
ClickHouse · db/queries/lost_property.sql
1
-- sgl.query(HandIn, exec)1
-- sgl.query(HandIn, exec)2
INSERT INTO lost_property (item, colour, found_at, days_held) VALUES ($1, $2, $3, $4);2
INSERT INTO lost_property (id, item, colour, found_at, days_held)3
VALUES ({id:UInt64}, {item:String}, {colour:String}, {found_at:String}, {days_held:UInt32});Claimdiffers
PostgreSQL · db/queries/lost_property.sql
ClickHouse · db/queries/lost_property.sql
1
-- sgl.query(Claim, execrows)1
-- sgl.query(Claim, asyncexec)2
UPDATE lost_property SET claimed = true, owner_phone = $1 WHERE id = $2;2
ALTER TABLE lost_property3
UPDATE claimed = true, owner_phone = {owner_phone:Nullable(String)}4
WHERE id = {id:UInt64};SearchUnclaimeddiffers
PostgreSQL · db/queries/lost_property.sql
ClickHouse · db/queries/lost_property.sql
1
-- sgl.query(SearchUnclaimed, many, dynamic: runtime)1
-- sgl.query(SearchUnclaimed, many, dynamic: runtime)2
SELECT id, item, colour, found_at, days_held2
SELECT id, item, colour, found_at, days_held3
FROM lost_property3
FROM lost_property4
WHERE NOT claimed4
WHERE claimed = falseWhat sgl generates
HandIndiffers
PostgreSQL
ClickHouse
1
func (queries *Queries) HandIn(ctx context.Context, params HandInParams) error1
func (queries *Queries) HandIn(ctx context.Context, params HandInParams) error2
2
3
type HandInParams struct {3
type HandInParams struct {4
ID uint644
Item string5
Item string5
Colour string6
Colour string6
FoundAt string7
FoundAt string7
DaysHeld int648
DaysHeld uint328
}9
}Claimdiffers
PostgreSQL
ClickHouse
1
func (queries *Queries) Claim(ctx context.Context, params ClaimParams) (int64, error)1
func (queries *Queries) Claim(ctx context.Context, params ClaimParams) error2
2
3
type ClaimParams struct {3
type ClaimParams struct {4
OwnerPhone *string4
OwnerPhone *string5
ID int645
ID uint646
}6
}SearchUnclaimeddiffers
PostgreSQL
ClickHouse
1
func (queries *Queries) SearchUnclaimed() *SearchUnclaimedBuilder1
func (queries *Queries) SearchUnclaimed() *SearchUnclaimedBuilder2
2
3
type SearchUnclaimedRow struct {3
type SearchUnclaimedRow struct {4
ID int64 `json:"id"`4
ID uint64 `json:"id"`5
Item string `json:"item"`5
Item string `json:"item"`6
Colour string `json:"colour"`6
Colour string `json:"colour"`7
FoundAt string `json:"found_at"`7
FoundAt string `json:"found_at"`8
DaysHeld int64 `json:"days_held"`8
DaysHeld uint32 `json:"days_held"`9
}9
}The program
example.godiffers
PostgreSQL
ClickHouse
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/014_runtime_builder/internal/store"10
"seagull.sh/seagull/examples/clickhouse/014_runtime_builder/internal/store"11
)11
)12
12
13
// run searches the pier's unclaimed lost property. dynamic: runtime turns SearchUnclaimed13
// run searches the pier's unclaimed lost property. dynamic: runtime turns SearchUnclaimed14
// into a builder: Where, OrderBy, Limit and Offset add to the SQL, and All, One or Count14
// into a builder: Where, OrderBy, Limit and Offset add to the SQL, and All, One or Count15
// run it. The base query already has WHERE NOT claimed, so each Where is joined with AND,15
// run it. The base query already has WHERE claimed = false, so each Where is joined with16
// which sgl vet points out as hint Q022. The builder inserts its filters, order and limit16
// AND, which sgl vet points out as hint Q022. The builder inserts its filters, order and17
// at the places the query leaves for them, before any LIMIT or trailing semicolon, and17
// limit at the places the query leaves for them, before any LIMIT or trailing semicolon,18
// Count replaces the SELECT list with COUNT(*), wrapping the whole query instead when it18
// and Count replaces the SELECT list with COUNT(*), wrapping the whole query instead when19
// groups or is DISTINCT (Q023).19
// it groups or is DISTINCT (Q023).20
//20
//21
// The builder is safe with untrusted input. Where and OrderBy accept only the columns the21
// The builder is safe with untrusted input. Where and OrderBy accept only the columns the22
// query selects, so owner_phone, which the table has but the query leaves out, cannot be22
// query selects, so owner_phone, which the table has but the query leaves out, cannot be23
// searched. Operators and directions come from fixed allow-lists, and every value is23
// searched. Operators and directions come from fixed allow-lists, and every value is24
// bound as a numbered $N parameter after the base query's own, never written into the24
// bound as a parameter, never written into the SQL. A refused input does not panic: the25
// SQL. A refused input does not panic: the terminal returns ErrSglUnknownColumn,25
// terminal returns ErrSglUnknownColumn, ErrSglUnknownOperator or ErrSglUnknownDirection.26
// ErrSglUnknownOperator or ErrSglUnknownDirection.27
//26
//27
// Claim is an ALTER TABLE ... UPDATE mutation, so it is asyncexec; the connection sets28
// mutations_sync=2, so the claimed phone is gone from the searches that follow.29
//28
// Takes database (*sql.DB) which is a migrated database.30
// Takes database (*sql.DB) which is a migrated database.29
// Takes output (io.Writer) which receives the searches and the refusals.31
// Takes output (io.Writer) which receives the searches and the refusals.30
//32
//31
// Returns error when a query fails or an unsafe input is not refused.33
// Returns error when a query fails or an unsafe input is not refused.32
func run(ctx context.Context, database *sql.DB, output io.Writer) error {34
func run(ctx context.Context, database *sql.DB, output io.Writer) error {33
queries := store.New(database)35
queries := store.New(database)34
36
35
items := []store.HandInParams{37
items := []store.HandInParams{36
{Item: "umbrella", Colour: "red", FoundAt: "the pier", DaysHeld: 40},38
{ID: 1, Item: "umbrella", Colour: "red", FoundAt: "the pier", DaysHeld: 40},37
{Item: "sunglasses", Colour: "black", FoundAt: "the beach", DaysHeld: 12},39
{ID: 2, Item: "sunglasses", Colour: "black", FoundAt: "the beach", DaysHeld: 12},38
{Item: "bucket and spade", Colour: "red", FoundAt: "the beach", DaysHeld: 3},40
{ID: 3, Item: "bucket and spade", Colour: "red", FoundAt: "the beach", DaysHeld: 3},39
{Item: "umbrella", Colour: "blue", FoundAt: "the cafe", DaysHeld: 33},41
{ID: 4, Item: "umbrella", Colour: "blue", FoundAt: "the cafe", DaysHeld: 33},40
{Item: "phone", Colour: "black", FoundAt: "the pier", DaysHeld: 1},42
{ID: 5, Item: "phone", Colour: "black", FoundAt: "the pier", DaysHeld: 1},41
}43
}42
for _, item := range items {44
for _, item := range items {43
if err := queries.HandIn(ctx, item); err != nil {45
if err := queries.HandIn(ctx, item); err != nil {44
return fmt.Errorf("handing in a %s: %w", item.Item, err)46
return fmt.Errorf("handing in a %s: %w", item.Item, err)45
}47
}46
}48
}47
if _, err := queries.Claim(ctx, store.ClaimParams{OwnerPhone: new("07700 900123"), ID: 5}); err != nil {49
if err := queries.Claim(ctx, store.ClaimParams{OwnerPhone: new("07700 900123"), ID: 5}); err != nil {48
return fmt.Errorf("claiming the phone: %w", err)50
return fmt.Errorf("claiming the phone: %w", err)49
}51
}50
52
51
red, err := queries.SearchUnclaimed().Where("colour", "=", "red").All(ctx)53
red, err := queries.SearchUnclaimed().Where("colour", "=", "red").OrderBy("id", "ASC").All(ctx)52
if err != nil {54
if err != nil {53
return fmt.Errorf("finding red items: %w", err)55
return fmt.Errorf("finding red items: %w", err)54
}56
}55
fmt.Fprintln(output, "red:")57
fmt.Fprintln(output, "red:")56
printItems(output, red)58
printItems(output, red)57
59
58
longest, err := queries.SearchUnclaimed().60
longest, err := queries.SearchUnclaimed().59
Where("days_held", ">=", 30).61
Where("days_held", ">=", 30).60
OrderBy("days_held", "DESC").62
OrderBy("days_held", "DESC").61
Limit(2).63
Limit(2).62
All(ctx)64
All(ctx)63
if err != nil {65
if err != nil {64
return fmt.Errorf("finding long-held items: %w", err)66
return fmt.Errorf("finding long-held items: %w", err)65
}67
}66
fmt.Fprintln(output, "held 30 days or more, longest first:")68
fmt.Fprintln(output, "held 30 days or more, longest first:")67
printItems(output, longest)69
printItems(output, longest)68
70
69
umbrellas, err := queries.SearchUnclaimed().Where("item", "=", "umbrella").Count(ctx)71
umbrellas, err := queries.SearchUnclaimed().Where("item", "=", "umbrella").Count(ctx)70
if err != nil {72
if err != nil {71
return fmt.Errorf("counting umbrellas: %w", err)73
return fmt.Errorf("counting umbrellas: %w", err)72
}74
}73
fmt.Fprintf(output, "unclaimed umbrellas: %d\n", umbrellas)75
fmt.Fprintf(output, "unclaimed umbrellas: %d\n", umbrellas)74
76
75
tautology, err := queries.SearchUnclaimed().Where("item", "=", "' OR '1'='1").All(ctx)77
tautology, err := queries.SearchUnclaimed().Where("item", "=", "' OR '1'='1").All(ctx)76
if err != nil {78
if err != nil {77
return fmt.Errorf("searching for a quoted value: %w", err)79
return fmt.Errorf("searching for a quoted value: %w", err)78
}80
}79
fmt.Fprintf(output, "items named ' OR '1'='1: %d, as the value is bound, not run\n", len(tautology))81
fmt.Fprintf(output, "items named ' OR '1'='1: %d, as the value is bound, not run\n", len(tautology))80
82
81
attempts := []struct {83
attempts := []struct {82
label string84
label string83
search *store.SearchUnclaimedBuilder85
search *store.SearchUnclaimedBuilder84
}{86
}{85
{label: "a column the query does not select", search: queries.SearchUnclaimed().Where("owner_phone", "IS NOT NULL", nil)},87
{label: "a column the query does not select", search: queries.SearchUnclaimed().Where("owner_phone", "IS NOT NULL", nil)},86
{label: "SQL in a column name", search: queries.SearchUnclaimed().OrderBy("id; DROP TABLE lost_property", "ASC")},88
{label: "SQL in a column name", search: queries.SearchUnclaimed().OrderBy("id; DROP TABLE lost_property", "ASC")},87
{label: "an operator outside the allow-list", search: queries.SearchUnclaimed().Where("colour", "= 'red' OR 1=1 --", "red")},89
{label: "an operator outside the allow-list", search: queries.SearchUnclaimed().Where("colour", "= 'red' OR 1=1 --", "red")},88
{label: "a direction outside the allow-list", search: queries.SearchUnclaimed().OrderBy("days_held", "DESC; DROP TABLE lost_property")},90
{label: "a direction outside the allow-list", search: queries.SearchUnclaimed().OrderBy("days_held", "DESC; DROP TABLE lost_property")},89
}91
}90
for _, attempt := range attempts {92
for _, attempt := range attempts {91
_, err := attempt.search.All(ctx)93
_, err := attempt.search.All(ctx)92
switch {94
switch {93
case errors.Is(err, store.ErrSglUnknownColumn), errors.Is(err, store.ErrSglUnknownOperator), errors.Is(err, store.ErrSglUnknownDirection):95
case errors.Is(err, store.ErrSglUnknownColumn), errors.Is(err, store.ErrSglUnknownOperator), errors.Is(err, store.ErrSglUnknownDirection):94
fmt.Fprintf(output, "refused %s: %v\n", attempt.label, err)96
fmt.Fprintf(output, "refused %s: %v\n", attempt.label, err)95
case err == nil:97
case err == nil:96
return fmt.Errorf("%s: got no error, want a refusal", attempt.label)98
return fmt.Errorf("%s: got no error, want a refusal", attempt.label)97
default:99
default:98
return fmt.Errorf("%s: got %w, want a refusal", attempt.label, err)100
return fmt.Errorf("%s: got %w, want a refusal", attempt.label, err)99
}101
}100
}102
}101
return nil103
return nil102
}104
}103
105
104
// printItems writes one line per item.106
// printItems writes one line per item.105
//107
//106
// Takes output (io.Writer) which receives the lines.108
// Takes output (io.Writer) which receives the lines.107
// Takes items ([]store.SearchUnclaimedRow) which are the items to print.109
// Takes items ([]store.SearchUnclaimedRow) which are the items to print.108
func printItems(output io.Writer, items []store.SearchUnclaimedRow) {110
func printItems(output io.Writer, items []store.SearchUnclaimedRow) {109
for _, item := range items {111
for _, item := range items {110
fmt.Fprintf(output, " #%d %s %s, found at %s, held %d days\n", item.ID, item.Colour, item.Item, item.FoundAt, item.DaysHeld)112
fmt.Fprintf(output, " #%d %s %s, found at %s, held %d days\n", item.ID, item.Colour, item.Item, item.FoundAt, item.DaysHeld)111
}113
}112
}114
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | PostgreSQL | ClickHouse |
|---|---|---|---|
| HandIn | parameter days_held | int64 | uint32 |
| Claim | parameter id | int64 | uint64 |
| SearchUnclaimed | column id | int64 | uint64 |
| SearchUnclaimed | column days_held | int64 | uint32 |