Nullability through joins, COALESCE and aggregates: TimescaleDB and ClickHouse
Every file of the TimescaleDB and ClickHouse versions side by side, with the lines that differ marked. 3 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
TimescaleDB
ClickHouse
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: colony4
- name: colony5
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_gulls.up.sqldiffers
TimescaleDB
ClickHouse
1
CREATE TABLE gulls (1
CREATE TABLE gulls (2
id BIGINT GENERATED ALWAYS AS IDENTITY 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 BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,8
id UInt64,9
gull_id BIGINT NOT NULL REFERENCES gulls (id),9
gull_id UInt64,10
seen_on DATE 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
TimescaleDB
ClickHouse
1
DROP TABLE sightings;1
DROP TABLE sightings;2
DROP TABLE gulls;2
DROP TABLE gulls;Queries
RingGulldiffers
TimescaleDB · 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 ($1, $2);2
INSERT INTO gulls (id, ring, nickname) VALUES ({id:UInt64}, {ring:String}, {nickname:Nullable(String)});RecordSightingdiffers
TimescaleDB · 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 ($1, $2, $3);2
INSERT INTO sightings (id, gull_id, seen_on, place) VALUES ({id:UInt64}, {gull_id:UInt64}, {seen_on:Date}, {place:String});ListSightingsidentical
TimescaleDB · 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
TimescaleDB · 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
TimescaleDB
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
TimescaleDB
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
GullID uint645
SeenOn time.Time6
SeenOn time.Time6
Place string7
Place string7
}8
}ListSightingsdiffers
TimescaleDB
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
TimescaleDB
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 *time.Time `json:"last_seen"`7
LastSeen *time.Time `json:"last_seen"`8
}8
}The program
example.godiffers
TimescaleDB
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
"time"9
9
10
"seagull.sh/seagull/examples/postgres/004_nullability/internal/store"10
"seagull.sh/seagull/examples/clickhouse/004_nullability/internal/store"11
)11
)12
12
13
// 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.14
// 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 is15
// a pointer:15
// a pointer:16
//16
//17
// - 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 a18
// column;18
// column; a ClickHouse column is never NULL unless its type is Nullable(T);19
// - 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 JOIN20
// NULL place, so Place is *string;20
// fills an unmatched row with each column's default value instead of NULL. Place is a21
// - 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 empty22
// - COUNT is never NULL, but MAX over no rows is, so TimesSeen is int64 and LastSeen is22
// one;23
// *time.Time, keeping the DATE column's type.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.24
//28
//25
// Takes database (*sql.DB) which is a migrated database.29
// Takes database (*sql.DB) which is a migrated database.26
// Takes output (io.Writer) which receives both listings.30
// Takes output (io.Writer) which receives both listings.27
//31
//28
// Returns error when a query fails.32
// Returns error when a query fails.29
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 {30
queries := store.New(database)34
queries := store.New(database)31
35
32
gulls := []store.RingGullParams{36
gulls := []store.RingGullParams{33
{Ring: "GB1001", Nickname: new("Steven")},37
{ID: 1, Ring: "GB1001", Nickname: new("Steven")},34
{Ring: "GB1002", Nickname: nil},38
{ID: 2, Ring: "GB1002", Nickname: nil},35
{Ring: "GB1003", Nickname: new("Gwen")},39
{ID: 3, Ring: "GB1003", Nickname: new("Gwen")},36
}40
}37
for _, gull := range gulls {41
for _, gull := range gulls {38
if err := queries.RingGull(ctx, gull); err != nil {42
if err := queries.RingGull(ctx, gull); err != nil {39
return fmt.Errorf("ringing %s: %w", gull.Ring, err)43
return fmt.Errorf("ringing %s: %w", gull.Ring, err)40
}44
}41
}45
}42
sightings := []store.RecordSightingParams{46
sightings := []store.RecordSightingParams{43
{GullID: 1, SeenOn: mayDay(1), Place: "the pier"},47
{ID: 1, GullID: 1, SeenOn: day(1), Place: "the pier"},44
{GullID: 1, SeenOn: mayDay(3), Place: "the chip shop"},48
{ID: 2, GullID: 1, SeenOn: day(3), Place: "the chip shop"},45
{GullID: 2, SeenOn: mayDay(2), Place: "the harbour wall"},49
{ID: 3, GullID: 2, SeenOn: day(2), Place: "the harbour wall"},46
}50
}47
for _, sighting := range sightings {51
for _, sighting := range sightings {48
if err := queries.RecordSighting(ctx, sighting); err != nil {52
if err := queries.RecordSighting(ctx, sighting); err != nil {49
return fmt.Errorf("recording a sighting: %w", err)53
return fmt.Errorf("recording a sighting: %w", err)50
}54
}51
}55
}52
56
53
rows, err := queries.ListSightings(ctx)57
rows, err := queries.ListSightings(ctx)54
if err != nil {58
if err != nil {55
return fmt.Errorf("listing sightings: %w", err)59
return fmt.Errorf("listing sightings: %w", err)56
}60
}57
fmt.Fprintln(output, "ring nickname place")61
fmt.Fprintln(output, "ring nickname place")58
for _, row := range rows {62
for _, row := range rows {59
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))60
}64
}61
65
62
summaries, err := queries.SummariseGulls(ctx)66
summaries, err := queries.SummariseGulls(ctx)63
if err != nil {67
if err != nil {64
return fmt.Errorf("summarising gulls: %w", err)68
return fmt.Errorf("summarising gulls: %w", err)65
}69
}66
fmt.Fprintln(output, "\nring known as times seen last seen")70
fmt.Fprintln(output, "\nring known as times seen last seen")67
for _, summary := range summaries {71
for _, summary := range summaries {68
lastSeen := "NULL"72
lastSeen := "NULL"69
if summary.LastSeen != nil {73
if summary.LastSeen != nil {70
lastSeen = summary.LastSeen.Format(time.DateOnly)74
lastSeen = summary.LastSeen.Format(time.DateOnly)71
}75
}72
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, lastSeen)76
fmt.Fprintf(output, "%-7s %-12s %-11d %s\n", summary.Ring, summary.KnownAs, summary.TimesSeen, lastSeen)73
}77
}74
return nil78
return nil75
}79
}76
80
77
// mayDay returns a day in May 2026, for the DATE column.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.78
//83
//79
// Takes day (int) which is the day of the month.84
// Takes date (int) which is the day of the month.80
//85
//81
// Returns time.Time which is midnight UTC on that day.86
// Returns time.Time which is the date.82
func mayDay(day int) time.Time {87
func day(date int) time.Time {83
return time.Date(2026, time.May, day, 0, 0, 0, 0, time.UTC)88
return time.Date(2026, time.May, date, 0, 0, 0, 0, time.UTC)84
}89
}85
90
86
// 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.87
//92
//88
// Takes value (*string) which is a nullable field.93
// Takes value (*string) which is a nullable field.89
//94
//90
// Returns string which is the text to print.95
// Returns string which is the text to print.91
func orNull(value *string) string {96
func orNull(value *string) string {92
if value == nil {97
if value == nil {93
return "NULL"98
return "NULL"94
}99
}95
return *value100
return *value96
}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 | TimescaleDB | ClickHouse |
|---|---|---|---|
| RecordSighting | parameter gull_id | int64 | uint64 |
| ListSightings | column place | *string | string |
| SummariseGulls | column times_seen | int64 | uint64 |