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