Arrays and lists as typed slices: DuckDB and PostgreSQL
Every file of the DuckDB and PostgreSQL versions side by side, with the lines that differ marked. 5 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
DuckDB
PostgreSQL
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: voyages4
- name: voyages5
dialect: duckdb5
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_voyages.up.sqldiffers
DuckDB
PostgreSQL
1
CREATE SEQUENCE voyage_ids;2
3
CREATE TABLE voyages (1
CREATE TABLE voyages (4
id BIGINT PRIMARY KEY DEFAULT nextval('voyage_ids'),2
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,5
boat VARCHAR NOT NULL,3
boat TEXT NOT NULL,6
ports VARCHAR[] NOT NULL,4
ports TEXT[] NOT NULL,7
catch_kg INTEGER[] NOT NULL,5
catch_kg BIGINT[] NOT NULL,8
sightings VARCHAR[]6
sightings TEXT[]9
);7
);8
-- sgl.column(voyages.ports, non_null_elements: true)db/migrations/0001_voyages.down.sqldiffers
DuckDB
PostgreSQL
1
DROP TABLE voyages;1
DROP TABLE voyages;2
DROP SEQUENCE voyage_ids;Queries
LogVoyageidentical
DuckDB · db/queries/voyages.sql
PostgreSQL · db/queries/voyages.sql
1
-- sgl.query(LogVoyage, exec)1
-- sgl.query(LogVoyage, exec)2
INSERT INTO voyages (boat, ports, catch_kg, sightings) VALUES ($1, $2, $3, $4);2
INSERT INTO voyages (boat, ports, catch_kg, sightings) VALUES ($1, $2, $3, $4);ListVoyagesidentical
DuckDB · db/queries/voyages.sql
PostgreSQL · db/queries/voyages.sql
1
-- sgl.query(ListVoyages, many)1
-- sgl.query(ListVoyages, many)2
SELECT id, boat, ports, catch_kg, sightings FROM voyages ORDER BY id;2
SELECT id, boat, ports, catch_kg, sightings FROM voyages ORDER BY id;VoyagesCallingAtdiffers
DuckDB · db/queries/voyages.sql
PostgreSQL · db/queries/voyages.sql
1
-- sgl.query(VoyagesCallingAt, many)1
-- sgl.query(VoyagesCallingAt, many)2
-- $1 as sgl.param(port, type: varchar)2
-- $1 as sgl.param(port)3
SELECT boat, ports[1] AS first_port, array_length(ports) AS stops3
SELECT boat, ports[1] AS first_port, cardinality(ports) AS stops4
FROM voyages4
FROM voyages5
WHERE $1 = ANY(ports)5
WHERE $1::text = ANY(ports)6
ORDER BY id;6
ORDER BY id;VoyagesPerBoatidentical
DuckDB · db/queries/voyages.sql
PostgreSQL · db/queries/voyages.sql
1
-- sgl.query(VoyagesPerBoat, many)1
-- sgl.query(VoyagesPerBoat, many)2
SELECT boat, array_agg(id ORDER BY id) AS voyages2
SELECT boat, array_agg(id ORDER BY id) AS voyages3
FROM voyages3
FROM voyages4
GROUP BY boat4
GROUP BY boat5
ORDER BY boat;5
ORDER BY boat;What sgl generates
LogVoyagediffers
DuckDB
PostgreSQL
1
func (queries *Queries) LogVoyage(ctx context.Context, params LogVoyageParams) error1
func (queries *Queries) LogVoyage(ctx context.Context, params LogVoyageParams) error2
2
3
type LogVoyageParams struct {3
type LogVoyageParams struct {4
Boat string4
Boat string5
Ports []string5
Ports []string6
CatchKg []int326
CatchKg []*int647
Sightings []string7
Sightings []*string8
}8
}ListVoyagesdiffers
DuckDB
PostgreSQL
1
func (queries *Queries) ListVoyages(ctx context.Context) ([]ListVoyagesRow, error)1
func (queries *Queries) ListVoyages(ctx context.Context) ([]ListVoyagesRow, error)2
2
3
type ListVoyagesRow struct {3
type ListVoyagesRow struct {4
ID int64 `json:"id"`4
ID int64 `json:"id"`5
Boat string `json:"boat"`5
Boat string `json:"boat"`6
Ports []*string `json:"ports"`6
Ports []string `json:"ports"`7
CatchKg []*int32 `json:"catch_kg"`7
CatchKg []*int64 `json:"catch_kg"`8
Sightings []*string `json:"sightings"`8
Sightings []*string `json:"sightings"`9
}9
}VoyagesCallingAtdiffers
DuckDB
PostgreSQL
1
func (queries *Queries) VoyagesCallingAt(ctx context.Context, port string) ([]VoyagesCallingAtRow, error)1
func (queries *Queries) VoyagesCallingAt(ctx context.Context, port string) ([]VoyagesCallingAtRow, error)2
2
3
type VoyagesCallingAtRow struct {3
type VoyagesCallingAtRow struct {4
Boat string `json:"boat"`4
Boat string `json:"boat"`5
FirstPort *string `json:"first_port"`5
FirstPort *string `json:"first_port"`6
Stops int64 `json:"stops"`6
Stops int32 `json:"stops"`7
}7
}VoyagesPerBoatidentical
DuckDB
PostgreSQL
1
func (queries *Queries) VoyagesPerBoat(ctx context.Context) ([]VoyagesPerBoatRow, error)1
func (queries *Queries) VoyagesPerBoat(ctx context.Context) ([]VoyagesPerBoatRow, error)2
2
3
type VoyagesPerBoatRow struct {3
type VoyagesPerBoatRow struct {4
Boat string `json:"boat"`4
Boat string `json:"boat"`5
Voyages []int64 `json:"voyages"`5
Voyages []int64 `json:"voyages"`6
}6
}The program
example.godiffers
DuckDB
PostgreSQL
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
8
9
"seagull.sh/seagull/examples/duckdb/008_arrays/internal/store"9
"seagull.sh/seagull/examples/postgres/008_arrays/internal/store"10
)10
)11
11
12
// run logs three fishing voyages and reads them back. A DuckDB list column becomes a12
// run logs three fishing voyages and reads them back. An array column becomes a typed Go13
// typed Go slice: VARCHAR[] gives []*string and INTEGER[] gives []*int32, and array_agg13
// slice: TEXT[] gives []*string and BIGINT[] gives []*int64, and array_agg gives a slice14
// over the NOT NULL id column gives []int64: the list can hold no NULL, so its elements14
// of its argument's type, here []int64: the id column it collects is NOT NULL, so the15
// are plain values.15
// array can hold no NULL and its elements are plain values. A slice parameter is sent as16
// a PostgreSQL array, and a nil slice is sent as NULL.16
//17
//17
// The elements are pointers because DuckDB can hold NULL in any list, whatever the18
// The elements are pointers because PostgreSQL can hold NULL in any array, whatever the18
// column's type says, so a NULL element reads as nil, which elements prints as NULL.19
// column's type says, so a NULL element reads as nil, which elements prints as NULL.19
// Parameters keep plain elements, so LogVoyage takes []string and []int32. When the data20
// Parameters take the same pointers, so LogVoyage takes []*int64 for catch_kg and20
// never holds a NULL element, non_null_elements: true opts out, in an sgl.column line of21
// []*string for sightings, and a nil element writes NULL; values builds them from plain21
// a query or migration or as go.types.non_null_elements in sgl.yaml: the elements then22
// values. When the data never holds a NULL element, non_null_elements: true opts out, in22
// read as plain values, and a NULL one fails the read.23
// an sgl.column line of a query or migration or as go.types.non_null_elements in23
//24
// sgl.yaml: the elements then read and bind as plain values, and a NULL one fails the24
// A slice parameter is bound as a DuckDB list. duckdb-go would bind a nil slice as an25
// read. The migration declares it for ports, as a voyage never calls at a NULL port, so25
// empty list, so the generated code converts the nullable sightings parameter and binds a26
// Ports is a plain []string in the model, the row and the parameters alike.26
// nil slice as NULL, as other drivers do: Salty Chip's Sightings: nil is stored as NULL,27
// and the second Kittiwake voyage's empty slice as an empty list.28
//27
//29
// The generated SQL selects each list column as it is, and the generated code reads the28
// database/sql cannot scan a PostgreSQL array, so the generated SQL wraps each array30
// list duckdb-go returns into the slice; open internal/store to see it. A NULL list29
// column in to_json() and decodes the JSON into the slice; open internal/store to see it.31
// therefore scans to a nil slice, and an empty list to an empty one.30
// A NULL array therefore scans to a nil slice, and an empty array to an empty one.32
//31
//33
// VoyagesCallingAt searches inside a list with $1 = ANY(ports), whose parameter is pinned32
// VoyagesCallingAt searches inside an array with $1::text = ANY(ports). The cast gives34
// with sgl.param(port, type: varchar) as inference leaves it as any, and reads one33
// the parameter its type, for Seagull, which would otherwise leave it as any, and for35
// element with ports[1], which is *string because a missing element is NULL. DuckDB lists34
// CockroachDB, which cannot work it out from ANY. ports[1] reads one element, and is36
// count from 1, like PostgreSQL arrays.35
// *string because a missing element is NULL.37
//36
//38
// Takes database (*sql.DB) which is a migrated database.37
// Takes database (*sql.DB) which is a migrated database.39
// Takes output (io.Writer) which receives the voyages and the searches.38
// Takes output (io.Writer) which receives the voyages and the searches.40
//39
//41
// Returns error when a query fails.40
// Returns error when a query fails.42
func run(ctx context.Context, database *sql.DB, output io.Writer) error {41
func run(ctx context.Context, database *sql.DB, output io.Writer) error {43
queries := store.New(database)42
queries := store.New(database)44
43
45
voyages := []store.LogVoyageParams{44
voyages := []store.LogVoyageParams{46
{Boat: "Kittiwake", Ports: []string{"Newlyn", "St Ives", "Mousehole"}, CatchKg: []int32{120, 85}, Sightings: []string{"dolphin", "basking shark"}},45
{Boat: "Kittiwake", Ports: []string{"Newlyn", "St Ives", "Mousehole"}, CatchKg: values[int64](120, 85), Sightings: values("dolphin", "basking shark")},47
{Boat: "Salty Chip", Ports: []string{"Penzance", "Mousehole"}, CatchKg: []int32{40}, Sightings: nil},46
{Boat: "Salty Chip", Ports: []string{"Penzance", "Mousehole"}, CatchKg: values[int64](40), Sightings: nil},48
{Boat: "Kittiwake", Ports: []string{"Newlyn", "Lamorna"}, CatchKg: []int32{60, 30, 15}, Sightings: []string{}},47
{Boat: "Kittiwake", Ports: []string{"Newlyn", "Lamorna"}, CatchKg: values[int64](60, 30, 15), Sightings: []*string{}},49
}48
}50
for _, voyage := range voyages {49
for _, voyage := range voyages {51
if err := queries.LogVoyage(ctx, voyage); err != nil {50
if err := queries.LogVoyage(ctx, voyage); err != nil {52
return fmt.Errorf("logging %s's voyage: %w", voyage.Boat, err)51
return fmt.Errorf("logging %s's voyage: %w", voyage.Boat, err)53
}52
}54
}53
}55
54
56
logged, err := queries.ListVoyages(ctx)55
logged, err := queries.ListVoyages(ctx)57
if err != nil {56
if err != nil {58
return fmt.Errorf("listing voyages: %w", err)57
return fmt.Errorf("listing voyages: %w", err)59
}58
}60
for _, voyage := range logged {59
for _, voyage := range logged {61
sightings := elements(voyage.Sightings)60
sightings := elements(voyage.Sightings)62
if voyage.Sightings == nil {61
if voyage.Sightings == nil {63
sightings = "NULL"62
sightings = "NULL"64
}63
}65
fmt.Fprintf(output, "voyage %d %-10s ports %s, catch %s kg, sightings %s\n", voyage.ID, voyage.Boat, elements(voyage.Ports), elements(voyage.CatchKg), sightings)64
fmt.Fprintf(output, "voyage %d %-10s ports %s, catch %s kg, sightings %s\n", voyage.ID, voyage.Boat, voyage.Ports, elements(voyage.CatchKg), sightings)66
}65
}67
66
68
calling, err := queries.VoyagesCallingAt(ctx, "Mousehole")67
calling, err := queries.VoyagesCallingAt(ctx, "Mousehole")69
if err != nil {68
if err != nil {70
return fmt.Errorf("finding voyages calling at Mousehole: %w", err)69
return fmt.Errorf("finding voyages calling at Mousehole: %w", err)71
}70
}72
fmt.Fprintln(output, "\ncalling at Mousehole:")71
fmt.Fprintln(output, "\ncalling at Mousehole:")73
for _, voyage := range calling {72
for _, voyage := range calling {74
fmt.Fprintf(output, " %-10s from %s, %d stops\n", voyage.Boat, *voyage.FirstPort, voyage.Stops)73
fmt.Fprintf(output, " %-10s from %s, %d stops\n", voyage.Boat, *voyage.FirstPort, voyage.Stops)75
}74
}76
75
77
perBoat, err := queries.VoyagesPerBoat(ctx)76
perBoat, err := queries.VoyagesPerBoat(ctx)78
if err != nil {77
if err != nil {79
return fmt.Errorf("grouping voyages by boat: %w", err)78
return fmt.Errorf("grouping voyages by boat: %w", err)80
}79
}81
fmt.Fprintln(output, "\nvoyages per boat:")80
fmt.Fprintln(output, "\nvoyages per boat:")82
for _, boat := range perBoat {81
for _, boat := range perBoat {83
fmt.Fprintf(output, " %-10s %v\n", boat.Boat, boat.Voyages)82
fmt.Fprintf(output, " %-10s %v\n", boat.Boat, boat.Voyages)84
}83
}85
return nil84
return nil86
}85
}87
86
88
// elements returns a list's elements as fmt prints a slice, with NULL for each element87
// elements returns an array's elements as fmt prints a slice, with NULL for each element89
// the database holds NULL in.88
// the database holds NULL in.90
//89
//91
// Takes list ([]*T) which holds the elements, nil for a NULL one.90
// Takes array ([]*T) which holds the elements, nil for a NULL one.92
//91
//93
// Returns string which is the elements in brackets, separated by spaces.92
// Returns string which is the elements in brackets, separated by spaces.94
func elements[T any](list []*T) string {93
func elements[T any](array []*T) string {95
texts := make([]string, 0, len(list))94
texts := make([]string, 0, len(array))96
for _, element := range list {95
for _, element := range array {97
if element == nil {96
if element == nil {98
texts = append(texts, "NULL")97
texts = append(texts, "NULL")99
continue98
continue100
}99
}101
texts = append(texts, fmt.Sprint(*element))100
texts = append(texts, fmt.Sprint(*element))102
}101
}103
return fmt.Sprint(texts)102
return fmt.Sprint(texts)104
}103
}104
105
// values returns pointers to copies of plain values, as an array parameter whose elements106
// may be NULL takes them.107
//108
// Takes items (...T) which are the elements.109
//110
// Returns []*T which points at the copies, none of them nil.111
func values[T any](items ...T) []*T {112
pointed := make([]*T, len(items))113
for index := range items {114
pointed[index] = &items[index]115
}116
return pointed117
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | DuckDB | PostgreSQL |
|---|---|---|---|
| LogVoyage | parameter catch_kg | []int32 | []*int64 |
| LogVoyage | parameter sightings | []string | []*string |
| ListVoyages | column ports | []*string | []string |
| ListVoyages | column catch_kg | []*int32 | []*int64 |
| VoyagesCallingAt | column stops | int64 | int32 |