The runtime query builder: SQLite and CockroachDB
Every file of the SQLite and CockroachDB versions side by side, with the lines that differ marked.
Configuration
sgl.yamldiffers
SQLite
CockroachDB
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: pier4
- name: pier5
dialect: sqlite5
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/sqlMigrations
db/migrations/0001_lost_property.up.sqldiffers
SQLite
CockroachDB
1
CREATE TABLE lost_property (1
CREATE TABLE lost_property (2
id INTEGER PRIMARY KEY,2
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,3
item TEXT NOT NULL,3
item TEXT NOT NULL,4
colour TEXT NOT NULL,4
colour TEXT NOT NULL,5
found_at TEXT NOT NULL,5
found_at TEXT NOT NULL,6
days_held INTEGER NOT NULL,6
days_held BIGINT NOT NULL,7
claimed BOOLEAN NOT NULL DEFAULT 0,7
claimed BOOLEAN NOT NULL DEFAULT false,8
owner_phone TEXT8
owner_phone TEXT9
);9
);db/migrations/0001_lost_property.down.sqlidentical
SQLite
CockroachDB
1
DROP TABLE lost_property;1
DROP TABLE lost_property;Queries
HandIndiffers
SQLite · db/queries/lost_property.sql
CockroachDB · 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 (?, ?, ?, ?);2
INSERT INTO lost_property (item, colour, found_at, days_held) VALUES ($1, $2, $3, $4);Claimdiffers
SQLite · db/queries/lost_property.sql
CockroachDB · db/queries/lost_property.sql
1
-- sgl.query(Claim, execrows)1
-- sgl.query(Claim, execrows)2
UPDATE lost_property SET claimed = 1, owner_phone = ? WHERE id = ?;2
UPDATE lost_property SET claimed = true, owner_phone = $1 WHERE id = $2;SearchUnclaimeddiffers
SQLite · db/queries/lost_property.sql
CockroachDB · 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 claimed = 04
WHERE NOT claimedWhat sgl generates
HandInidentical
SQLite
CockroachDB
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
Item string4
Item string5
Colour string5
Colour string6
FoundAt string6
FoundAt string7
DaysHeld int647
DaysHeld int648
}8
}Claimidentical
SQLite
CockroachDB
1
func (queries *Queries) Claim(ctx context.Context, params ClaimParams) (int64, error)1
func (queries *Queries) Claim(ctx context.Context, params ClaimParams) (int64, error)2
2
3
type ClaimParams struct {3
type ClaimParams struct {4
OwnerPhone *string4
OwnerPhone *string5
ID int645
ID int646
}6
}SearchUnclaimedidentical
SQLite
CockroachDB
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 int64 `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 int64 `json:"days_held"`9
}9
}The program
example.godiffers
SQLite
CockroachDB
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/sqlite/014_runtime_builder/internal/store"10
"seagull.sh/seagull/examples/postgres/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 claimed = 0, so each Where is joined with AND,15
// run it. The base query already has WHERE NOT claimed, so each Where is joined with AND,16
// which sgl vet points out as hint Q022. The builder inserts its filters, order and limit16
// which sgl vet points out as hint Q022. The builder inserts its filters, order and limit17
// at the places the query leaves for them, before any LIMIT or trailing semicolon, and17
// at the places the query leaves for them, before any LIMIT or trailing semicolon, and18
// Count replaces the SELECT list with COUNT(*), wrapping the whole query instead when it18
// Count replaces the SELECT list with COUNT(*), wrapping the whole query instead when it19
// groups or is DISTINCT (Q023).19
// 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 parameter, never written into the SQL. A refused input does not panic: the24
// bound as a numbered $N parameter after the base query's own, never written into the25
// terminal returns ErrSglUnknownColumn, ErrSglUnknownOperator or ErrSglUnknownDirection.25
// SQL. A refused input does not panic: the terminal returns ErrSglUnknownColumn,26
// ErrSglUnknownOperator or ErrSglUnknownDirection.26
//27
//27
// Takes database (*sql.DB) which is a migrated database.28
// Takes database (*sql.DB) which is a migrated database.28
// Takes output (io.Writer) which receives the searches and the refusals.29
// Takes output (io.Writer) which receives the searches and the refusals.29
//30
//30
// Returns error when a query fails or an unsafe input is not refused.31
// Returns error when a query fails or an unsafe input is not refused.31
func run(ctx context.Context, database *sql.DB, output io.Writer) error {32
func run(ctx context.Context, database *sql.DB, output io.Writer) error {32
queries := store.New(database)33
queries := store.New(database)33
34
34
items := []store.HandInParams{35
items := []store.HandInParams{35
{Item: "umbrella", Colour: "red", FoundAt: "the pier", DaysHeld: 40},36
{Item: "umbrella", Colour: "red", FoundAt: "the pier", DaysHeld: 40},36
{Item: "sunglasses", Colour: "black", FoundAt: "the beach", DaysHeld: 12},37
{Item: "sunglasses", Colour: "black", FoundAt: "the beach", DaysHeld: 12},37
{Item: "bucket and spade", Colour: "red", FoundAt: "the beach", DaysHeld: 3},38
{Item: "bucket and spade", Colour: "red", FoundAt: "the beach", DaysHeld: 3},38
{Item: "umbrella", Colour: "blue", FoundAt: "the cafe", DaysHeld: 33},39
{Item: "umbrella", Colour: "blue", FoundAt: "the cafe", DaysHeld: 33},39
{Item: "phone", Colour: "black", FoundAt: "the pier", DaysHeld: 1},40
{Item: "phone", Colour: "black", FoundAt: "the pier", DaysHeld: 1},40
}41
}41
for _, item := range items {42
for _, item := range items {42
if err := queries.HandIn(ctx, item); err != nil {43
if err := queries.HandIn(ctx, item); err != nil {43
return fmt.Errorf("handing in a %s: %w", item.Item, err)44
return fmt.Errorf("handing in a %s: %w", item.Item, err)44
}45
}45
}46
}46
if _, err := queries.Claim(ctx, store.ClaimParams{OwnerPhone: new("07700 900123"), ID: 5}); err != nil {47
if _, err := queries.Claim(ctx, store.ClaimParams{OwnerPhone: new("07700 900123"), ID: 5}); err != nil {47
return fmt.Errorf("claiming the phone: %w", err)48
return fmt.Errorf("claiming the phone: %w", err)48
}49
}49
50
50
red, err := queries.SearchUnclaimed().Where("colour", "=", "red").All(ctx)51
red, err := queries.SearchUnclaimed().Where("colour", "=", "red").All(ctx)51
if err != nil {52
if err != nil {52
return fmt.Errorf("finding red items: %w", err)53
return fmt.Errorf("finding red items: %w", err)53
}54
}54
fmt.Fprintln(output, "red:")55
fmt.Fprintln(output, "red:")55
printItems(output, red)56
printItems(output, red)56
57
57
longest, err := queries.SearchUnclaimed().58
longest, err := queries.SearchUnclaimed().58
Where("days_held", ">=", 30).59
Where("days_held", ">=", 30).59
OrderBy("days_held", "DESC").60
OrderBy("days_held", "DESC").60
Limit(2).61
Limit(2).61
All(ctx)62
All(ctx)62
if err != nil {63
if err != nil {63
return fmt.Errorf("finding long-held items: %w", err)64
return fmt.Errorf("finding long-held items: %w", err)64
}65
}65
fmt.Fprintln(output, "held 30 days or more, longest first:")66
fmt.Fprintln(output, "held 30 days or more, longest first:")66
printItems(output, longest)67
printItems(output, longest)67
68
68
umbrellas, err := queries.SearchUnclaimed().Where("item", "=", "umbrella").Count(ctx)69
umbrellas, err := queries.SearchUnclaimed().Where("item", "=", "umbrella").Count(ctx)69
if err != nil {70
if err != nil {70
return fmt.Errorf("counting umbrellas: %w", err)71
return fmt.Errorf("counting umbrellas: %w", err)71
}72
}72
fmt.Fprintf(output, "unclaimed umbrellas: %d\n", umbrellas)73
fmt.Fprintf(output, "unclaimed umbrellas: %d\n", umbrellas)73
74
74
tautology, err := queries.SearchUnclaimed().Where("item", "=", "' OR '1'='1").All(ctx)75
tautology, err := queries.SearchUnclaimed().Where("item", "=", "' OR '1'='1").All(ctx)75
if err != nil {76
if err != nil {76
return fmt.Errorf("searching for a quoted value: %w", err)77
return fmt.Errorf("searching for a quoted value: %w", err)77
}78
}78
fmt.Fprintf(output, "items named ' OR '1'='1: %d, as the value is bound, not run\n", len(tautology))79
fmt.Fprintf(output, "items named ' OR '1'='1: %d, as the value is bound, not run\n", len(tautology))79
80
80
attempts := []struct {81
attempts := []struct {81
label string82
label string82
search *store.SearchUnclaimedBuilder83
search *store.SearchUnclaimedBuilder83
}{84
}{84
{label: "a column the query does not select", search: queries.SearchUnclaimed().Where("owner_phone", "IS NOT NULL", nil)},85
{label: "a column the query does not select", search: queries.SearchUnclaimed().Where("owner_phone", "IS NOT NULL", nil)},85
{label: "SQL in a column name", search: queries.SearchUnclaimed().OrderBy("id; DROP TABLE lost_property", "ASC")},86
{label: "SQL in a column name", search: queries.SearchUnclaimed().OrderBy("id; DROP TABLE lost_property", "ASC")},86
{label: "an operator outside the allow-list", search: queries.SearchUnclaimed().Where("colour", "= 'red' OR 1=1 --", "red")},87
{label: "an operator outside the allow-list", search: queries.SearchUnclaimed().Where("colour", "= 'red' OR 1=1 --", "red")},87
{label: "a direction outside the allow-list", search: queries.SearchUnclaimed().OrderBy("days_held", "DESC; DROP TABLE lost_property")},88
{label: "a direction outside the allow-list", search: queries.SearchUnclaimed().OrderBy("days_held", "DESC; DROP TABLE lost_property")},88
}89
}89
for _, attempt := range attempts {90
for _, attempt := range attempts {90
_, err := attempt.search.All(ctx)91
_, err := attempt.search.All(ctx)91
switch {92
switch {92
case errors.Is(err, store.ErrSglUnknownColumn), errors.Is(err, store.ErrSglUnknownOperator), errors.Is(err, store.ErrSglUnknownDirection):93
case errors.Is(err, store.ErrSglUnknownColumn), errors.Is(err, store.ErrSglUnknownOperator), errors.Is(err, store.ErrSglUnknownDirection):93
fmt.Fprintf(output, "refused %s: %v\n", attempt.label, err)94
fmt.Fprintf(output, "refused %s: %v\n", attempt.label, err)94
case err == nil:95
case err == nil:95
return fmt.Errorf("%s: got no error, want a refusal", attempt.label)96
return fmt.Errorf("%s: got no error, want a refusal", attempt.label)96
default:97
default:97
return fmt.Errorf("%s: got %w, want a refusal", attempt.label, err)98
return fmt.Errorf("%s: got %w, want a refusal", attempt.label, err)98
}99
}99
}100
}100
return nil101
return nil101
}102
}102
103
103
// printItems writes one line per item.104
// printItems writes one line per item.104
//105
//105
// Takes output (io.Writer) which receives the lines.106
// Takes output (io.Writer) which receives the lines.106
// Takes items ([]store.SearchUnclaimedRow) which are the items to print.107
// Takes items ([]store.SearchUnclaimedRow) which are the items to print.107
func printItems(output io.Writer, items []store.SearchUnclaimedRow) {108
func printItems(output io.Writer, items []store.SearchUnclaimedRow) {108
for _, item := range items {109
for _, item := range items {109
fmt.Fprintf(output, " #%d %s %s, found at %s, held %d days\n", item.ID, item.Colour, item.Item, item.FoundAt, item.DaysHeld)110
fmt.Fprintf(output, " #%d %s %s, found at %s, held %d days\n", item.ID, item.Colour, item.Item, item.FoundAt, item.DaysHeld)110
}111
}111
}112
}