Nullability through joins, COALESCE and aggregates: SQLite and ClickHouse
Every file of the SQLite and ClickHouse versions side by side, with the lines that differ marked. 5 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
SQLite
ClickHouse
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: colony4
- name: colony5
dialect: sqlite5
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/storeMigrations
db/migrations/0001_gulls.up.sqldiffers
SQLite
ClickHouse
1
CREATE TABLE gulls (1
CREATE TABLE gulls (2
id INTEGER PRIMARY KEY,2
id UInt64,3
ring TEXT NOT NULL UNIQUE,3
ring String,4
nickname TEXT4
nickname Nullable(String)5
);5
) ENGINE = MergeTree() ORDER BY id;6
6
7
CREATE TABLE sightings (7
CREATE TABLE sightings (8
id INTEGER PRIMARY KEY,8
id UInt64,9
gull_id INTEGER NOT NULL REFERENCES gulls (id),9
gull_id UInt64,10
seen_on TEXT NOT NULL,10
seen_on Date,11
place TEXT NOT NULL11
place String12
);12
) ENGINE = MergeTree() ORDER BY id;db/migrations/0001_gulls.down.sqlidentical
SQLite
ClickHouse
1
DROP TABLE sightings;1
DROP TABLE sightings;2
DROP TABLE gulls;2
DROP TABLE gulls;Queries
RingGulldiffers
SQLite · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(RingGull, exec)1
-- sgl.query(RingGull, exec)2
INSERT INTO gulls (ring, nickname) VALUES (?, ?);2
INSERT INTO gulls (id, ring, nickname) VALUES ({id:UInt64}, {ring:String}, {nickname:Nullable(String)});RecordSightingdiffers
SQLite · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(RecordSighting, exec)1
-- sgl.query(RecordSighting, exec)2
INSERT INTO sightings (gull_id, seen_on, place) VALUES (?, ?, ?);2
INSERT INTO sightings (id, gull_id, seen_on, place) VALUES ({id:UInt64}, {gull_id:UInt64}, {seen_on:Date}, {place:String});ListSightingsidentical
SQLite · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(ListSightings, many)1
-- sgl.query(ListSightings, many)2
SELECT gulls.ring, gulls.nickname, sightings.place2
SELECT gulls.ring, gulls.nickname, sightings.place3
FROM gulls3
FROM gulls4
LEFT JOIN sightings ON sightings.gull_id = gulls.id4
LEFT JOIN sightings ON sightings.gull_id = gulls.id5
ORDER BY gulls.id, sightings.seen_on;5
ORDER BY gulls.id, sightings.seen_on;SummariseGullsdiffers
SQLite · db/queries/gulls.sql
ClickHouse · db/queries/gulls.sql
1
-- sgl.query(SummariseGulls, many)1
-- sgl.query(SummariseGulls, many)2
SELECT2
SELECT3
gulls.ring,3
ring,4
COALESCE(gulls.nickname, 'ring ' || gulls.ring) AS known_as,4
coalesce(nickname, concat('ring ', ring)) AS known_as,5
COUNT(sightings.id) AS times_seen,5
count(seen_on) AS times_seen,6
MAX(sightings.seen_on) AS last_seen6
max(seen_on) AS last_seen7
FROM gulls7
FROM gulls8
LEFT JOIN sightings ON sightings.gull_id = gulls.id8
LEFT JOIN sightings ON sightings.gull_id = gulls.id9
GROUP BY gulls.id9
GROUP BY gulls.id, ring, nickname10
ORDER BY gulls.id;10
ORDER BY gulls.id11
SETTINGS join_use_nulls = 1;What sgl generates
RingGulldiffers
SQLite
ClickHouse
1
func (queries *Queries) RingGull(ctx context.Context, params RingGullParams) error1
func (queries *Queries) RingGull(ctx context.Context, params RingGullParams) error2
2
3
type RingGullParams struct {3
type RingGullParams struct {4
ID uint644
Ring string5
Ring string5
Nickname *string6
Nickname *string6
}7
}RecordSightingdiffers
SQLite
ClickHouse
1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error2
2
3
type RecordSightingParams struct {3
type RecordSightingParams struct {4
GullID int644
ID uint645
SeenOn string5
GullID uint646
SeenOn time.Time6
Place string7
Place string7
}8
}ListSightingsdiffers
SQLite
ClickHouse
1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)2
2
3
type ListSightingsRow struct {3
type ListSightingsRow struct {4
Ring string `json:"ring"`4
Ring string `json:"ring"`5
Nickname *string `json:"nickname"`5
Nickname *string `json:"nickname"`6
Place *string `json:"place"`6
Place string `json:"place"`7
}7
}SummariseGullsdiffers
SQLite
ClickHouse
1
func (queries *Queries) SummariseGulls(ctx context.Context) ([]SummariseGullsRow, error)1
func (queries *Queries) SummariseGulls(ctx context.Context) ([]SummariseGullsRow, error)2
2
3
type SummariseGullsRow struct {3
type SummariseGullsRow struct {4
Ring string `json:"ring"`4
Ring string `json:"ring"`5
KnownAs string `json:"known_as"`5
KnownAs string `json:"known_as"`6
TimesSeen int64 `json:"times_seen"`6
TimesSeen uint64 `json:"times_seen"`7
LastSeen *string `json:"last_seen"`7
LastSeen *time.Time `json:"last_seen"`8
}8
}The program
example.godiffers
SQLite
ClickHouse
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
"time"8
9
9
"seagull.sh/seagull/examples/sqlite/004_nullability/internal/store"10
"seagull.sh/seagull/examples/clickhouse/004_nullability/internal/store"10
)11
)11
12
12
// run rings three gulls, records where two of them were seen, and lists them twice.13
// run rings three gulls, records where two of them were seen, and lists them twice.13
// Seagull decides each field's nullability from the SQL, and a field that can be NULL is14
// Seagull decides each field's nullability from the SQL, and a field that can be NULL is14
// a pointer:15
// a pointer:15
//16
//16
// - gulls.nickname has no NOT NULL, so Nickname is *string, as a parameter and a17
// - gulls.nickname is Nullable(String), so Nickname is *string, as a parameter and a17
// column;18
// column; a ClickHouse column is never NULL unless its type is Nullable(T);18
// - sightings.place is NOT NULL, but the LEFT JOIN gives a gull with no sightings a19
// - ListSightings leaves join_use_nulls at the server's default, 0, so its LEFT JOIN19
// NULL place, so Place is *string;20
// fills an unmatched row with each column's default value instead of NULL. Place is a20
// - COALESCE with a non-NULL last argument is never NULL, so KnownAs is string;21
// String column, so it stays a string, and Gwen, who was never seen, gets an empty21
// - COUNT is never NULL, but MAX over no rows is, so TimesSeen is int64 and LastSeen is22
// one;22
// *string.23
// - SummariseGulls sets join_use_nulls = 1, so the unmatched row is NULL: count skips24
// it and gives 0, and max over no dates gives NULL, so TimesSeen is uint64 and25
// LastSeen is *time.Time. Without the setting, count would see Gwen's default row and26
// give 1;27
// - coalesce with a fallback that is never NULL is never NULL, so KnownAs is string.23
//28
//24
// Takes database (*sql.DB) which is a migrated database.29
// Takes database (*sql.DB) which is a migrated database.25
// Takes output (io.Writer) which receives both listings.30
// Takes output (io.Writer) which receives both listings.26
//31
//27
// Returns error when a query fails.32
// Returns error when a query fails.28
func run(ctx context.Context, database *sql.DB, output io.Writer) error {33
func run(ctx context.Context, database *sql.DB, output io.Writer) error {29
queries := store.New(database)34
queries := store.New(database)30
35
31
gulls := []store.RingGullParams{36
gulls := []store.RingGullParams{32
{Ring: "GB1001", Nickname: new("Steven")},37
{ID: 1, Ring: "GB1001", Nickname: new("Steven")},33
{Ring: "GB1002", Nickname: nil},38
{ID: 2, Ring: "GB1002", Nickname: nil},34
{Ring: "GB1003", Nickname: new("Gwen")},39
{ID: 3, Ring: "GB1003", Nickname: new("Gwen")},35
}40
}36
for _, gull := range gulls {41
for _, gull := range gulls {37
if err := queries.RingGull(ctx, gull); err != nil {42
if err := queries.RingGull(ctx, gull); err != nil {38
return fmt.Errorf("ringing %s: %w", gull.Ring, err)43
return fmt.Errorf("ringing %s: %w", gull.Ring, err)39
}44
}40
}45
}41
sightings := []store.RecordSightingParams{46
sightings := []store.RecordSightingParams{42
{GullID: 1, SeenOn: "2026-05-01", Place: "the pier"},47
{ID: 1, GullID: 1, SeenOn: day(1), Place: "the pier"},43
{GullID: 1, SeenOn: "2026-05-03", Place: "the chip shop"},48
{ID: 2, GullID: 1, SeenOn: day(3), Place: "the chip shop"},44
{GullID: 2, SeenOn: "2026-05-02", Place: "the harbour wall"},49
{ID: 3, GullID: 2, SeenOn: day(2), Place: "the harbour wall"},45
}50
}46
for _, sighting := range sightings {51
for _, sighting := range sightings {47
if err := queries.RecordSighting(ctx, sighting); err != nil {52
if err := queries.RecordSighting(ctx, sighting); err != nil {48
return fmt.Errorf("recording a sighting: %w", err)53
return fmt.Errorf("recording a sighting: %w", err)49
}54
}50
}55
}51
56
52
rows, err := queries.ListSightings(ctx)57
rows, err := queries.ListSightings(ctx)53
if err != nil {58
if err != nil {54
return fmt.Errorf("listing sightings: %w", err)59
return fmt.Errorf("listing sightings: %w", err)55
}60
}56
fmt.Fprintln(output, "ring nickname place")61
fmt.Fprintln(output, "ring nickname place")57
for _, row := range rows {62
for _, row := range rows {58
fmt.Fprintf(output, "%-7s %-9s %s\n", row.Ring, orNull(row.Nickname), orNull(row.Place))63
fmt.Fprintf(output, "%-7s %-9s %s\n", row.Ring, orNull(row.Nickname), orEmpty(row.Place))59
}64
}60
65
61
summaries, err := queries.SummariseGulls(ctx)66
summaries, err := queries.SummariseGulls(ctx)62
if err != nil {67
if err != nil {63
return fmt.Errorf("summarising gulls: %w", err)68
return fmt.Errorf("summarising gulls: %w", err)64
}69
}65
fmt.Fprintln(output, "\nring known as times seen last seen")70
fmt.Fprintln(output, "\nring known as times seen last seen")66
for _, summary := range summaries {71
for _, summary := range summaries {67
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, orNull(summary.LastSeen))72
lastSeen := "NULL"73
if summary.LastSeen != nil {74
lastSeen = summary.LastSeen.Format(time.DateOnly)75
}76
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, lastSeen)68
}77
}69
return nil78
return nil70
}79
}71
80
81
// day returns a date in May 2026, at midnight UTC. A Date parameter binds the calendar82
// date a time has in its own location.83
//84
// Takes date (int) which is the day of the month.85
//86
// Returns time.Time which is the date.87
func day(date int) time.Time {88
return time.Date(2026, time.May, date, 0, 0, 0, 0, time.UTC)89
}90
72
// orNull returns the text a nullable field holds, or NULL when the field is nil.91
// orNull returns the text a nullable field holds, or NULL when the field is nil.73
//92
//74
// Takes value (*string) which is a nullable field.93
// Takes value (*string) which is a nullable field.75
//94
//76
// Returns string which is the text to print.95
// Returns string which is the text to print.77
func orNull(value *string) string {96
func orNull(value *string) string {78
if value == nil {97
if value == nil {79
return "NULL"98
return "NULL"80
}99
}81
return *value100
return *value82
}101
}102
103
// orEmpty returns a string field's text, or (empty) when it holds the empty string, the104
// default value ClickHouse gives a String column in an unmatched outer-join row.105
//106
// Takes value (string) which is a String column.107
//108
// Returns string which is the text to print.109
func orEmpty(value string) string {110
if value == "" {111
return "(empty)"112
}113
return value114
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | SQLite | ClickHouse |
|---|---|---|---|
| RecordSighting | parameter gull_id | int64 | uint64 |
| RecordSighting | parameter seen_on | string | time.Time |
| ListSightings | column place | *string | string |
| SummariseGulls | column times_seen | int64 | uint64 |
| SummariseGulls | column last_seen | *string | *time.Time |